Design

Ecosistema completo para organización y gestión de contenido con interfaces espaciales

Widget System - Architecture & Design

Diseño técnico y decisiones arquitectónicas para @gograb-space/widget-system

🎯 Design Goals

  1. Zero Duplication: Reusar tipos y servicios existentes
  2. Minimal Changes: Mínimo impacto en código existente
  3. Clean Integration: Integración natural con arquitectura actual
  4. Future Proof: Extensible para features post-MVP

🏗️ Architecture Overview

System Context

┌──────────────────────────────────────────────────────────────────┐
│ EXISTING INFRASTRUCTURE (No Modifications)                        │
├──────────────────────────────────────────────────────────────────┤
│                                                                   │
│  @gograb-space/module-base                                        │
│  ├─ ModuleEmbeddableComponent (interface)                        │
│  │  └─ Defines widget contracts in module manifests              │
│  ├─ ComponentRegistryService                                      │
│  │  └─ Centralized registry for embeddable components            │
│  └─ ModuleParameterMetadata                                       │
│      └─ Widget input definitions                                 │
│                                                                   │
│  @gograb-space/module-config                                      │
│  ├─ ResolvedWidgetConfig (interface)                             │
│  │  ├─ id, widgetTypeId, displayName                             │
│  │  ├─ position: { x, y, width, height }  ← Grid positioning     │
│  │  ├─ enabled, componentType, permissions                       │
│  ├─ WidgetRegistryEntry (interface)                              │
│  │  └─ Widget metadata for catalog                               │
│  └─ AppConfigService                                              │
│      ├─ dashboardConfig() signal                                 │
│      └─ availableWidgets() signal                                │
│                                                                   │
│  @gograb-space/event-system                                       │
│  └─ EventBusService                                               │
│      └─ Widget-to-widget communication                           │
│                                                                   │
└──────────────────────────────────────────────────────────────────┘
                             ▼
┌──────────────────────────────────────────────────────────────────┐
│ @gograb-space/widget-system (NEW - UI Infrastructure)            │
├──────────────────────────────────────────────────────────────────┤
│                                                                   │
│  UI Components                                                    │
│  ├─ WidgetGridComponent                                          │
│  │  ├─ Renders widgets in CSS Grid layout                        │
│  │  ├─ Uses ResolvedWidgetConfig.position                        │
│  │  └─ Responsive breakpoint handling                            │
│  ├─ WidgetContainerComponent                                     │
│  │  ├─ Error boundary wrapper                                    │
│  │  ├─ Loading state management                                  │
│  │  └─ Dynamic component loading (NgComponentOutlet)             │
│  ├─ WidgetPlaceholderComponent                                   │
│  │  └─ Skeleton UI during loading                                │
│  └─ WidgetErrorComponent                                         │
│      └─ Error display with retry                                 │
│                                                                   │
│  Base Classes (Optional)                                          │
│  └─ BaseWidgetComponent                                           │
│      ├─ Event emission helpers                                   │
│      ├─ Lifecycle hooks                                          │
│      └─ Logger integration                                       │
│                                                                   │
│  Services                                                         │
│  ├─ WidgetLayoutService                                          │
│  │  ├─ Responsive breakpoint logic                               │
│  │  ├─ Grid positioning calculations                             │
│  │  └─ Layout validation                                         │
│  └─ WidgetCommunicationService                                   │
│      └─ Thin wrapper over EventBusService                        │
│                                                                   │
│  Models & Interfaces                                              │
│  ├─ GridConfig                                                    │
│  ├─ GridBreakpointConfig                                         │
│  └─ WidgetLifecycle (interface)                                  │
│                                                                   │
└──────────────────────────────────────────────────────────────────┘
                             ▼
┌──────────────────────────────────────────────────────────────────┐
│ gograb-admin (Application Usage)                                 │
├──────────────────────────────────────────────────────────────────┤
│                                                                   │
│  dashboard.component.ts                                           │
│  ├─ Consumes AppConfigService.dashboardConfig()                  │
│  ├─ Renders <lib-gs-widget-grid [widgets]="enabledWidgets()" />      │
│  └─ Minimal changes (< 20 LOC)                                   │
│                                                                   │
└──────────────────────────────────────────────────────────────────┘

📦 Component Architecture

WidgetGridComponent

Responsibility: Grid layout rendering with responsive breakpoints

@Component({
  selector: 'lib-gs-widget-grid',
  standalone: true,
  imports: [WidgetContainerComponent, CommonModule],
  template: `
    <div class="widget-grid"
         [style.--grid-columns]="gridColumns()"
         [style.--grid-gap]="gridConfig().gap + 'px'"
         [style.--row-height]="rowHeight()">

      @for (widget of enabledWidgets(); track widget.id) {
        <lib-gs-widget-container
          [widgetConfig]="widget"
          [style.grid-column]="getGridColumn(widget.position)"
          [style.grid-row]="getGridRow(widget.position)" />
      }
    </div>
  `,
  styles: [`
    .widget-grid {
      display: grid;
      grid-template-columns: repeat(var(--grid-columns), 1fr);
      gap: var(--grid-gap);
      grid-auto-rows: var(--row-height);
    }
  `],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class WidgetGridComponent implements OnInit {
  // === Inputs ===
  widgets = input.required<ResolvedWidgetConfig[]>();
  gridConfig = input<GridConfig>({ ...DEFAULT_GRID_CONFIG });

  // === Services ===
  private breakpointObserver = inject(BreakpointObserver);
  private destroyRef = inject(DestroyRef);

  // === State ===
  private currentBreakpoint = signal<'mobile' | 'tablet' | 'desktop'>('desktop');

  // === Computed ===
  enabledWidgets = computed(() =>
    this.widgets().filter(w => w.enabled)
  );

  gridColumns = computed(() => {
    const bp = this.currentBreakpoint();
    return this.gridConfig().breakpoints[bp].columns;
  });

  rowHeight = computed(() => {
    const bp = this.currentBreakpoint();
    const height = this.gridConfig().breakpoints[bp].rowHeight;
    return typeof height === 'number' ? `${height}px` : 'auto';
  });

  ngOnInit(): void {
    this.setupResponsiveBreakpoints();
  }

  private setupResponsiveBreakpoints(): void {
    this.breakpointObserver
      .observe([Breakpoints.XSmall, Breakpoints.Small, Breakpoints.Medium])
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(result => {
        if (result.breakpoints[Breakpoints.XSmall] || result.breakpoints[Breakpoints.Small]) {
          this.currentBreakpoint.set('mobile');
        } else if (result.breakpoints[Breakpoints.Medium]) {
          this.currentBreakpoint.set('tablet');
        } else {
          this.currentBreakpoint.set('desktop');
        }
      });
  }

  getGridColumn(position: ResolvedWidgetConfig['position']): string {
    return `${position.x + 1} / span ${position.width}`;
  }

  getGridRow(position: ResolvedWidgetConfig['position']): string {
    return `${position.y + 1} / span ${position.height}`;
  }
}

Key Decisions:


WidgetContainerComponent

Responsibility: Error boundary, loading states, dynamic component rendering

@Component({
  selector: 'lib-gs-widget-container',
  standalone: true,
  imports: [NgComponentOutlet, CommonModule, WidgetPlaceholderComponent, WidgetErrorComponent],
  template: `
    <div class="widget-container"
         [class.widget-loading]="state().loading"
         [class.widget-error]="state().error"
         [attr.aria-busy]="state().loading"
         [attr.aria-label]="widgetConfig().displayName">

      @if (state().loading) {
        <gs-widget-placeholder />
      } @else if (state().error) {
        <gs-widget-error
          [error]="state().error!"
          [widgetName]="widgetConfig().displayName"
          (retry)="handleRetry()" />
      } @else {
        <ng-container
          [ngComponentOutlet]="widgetConfig().componentType"
          [ngComponentOutletInputs]="componentInputs()" />
      }
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class WidgetContainerComponent {
  // === Inputs ===
  widgetConfig = input.required<ResolvedWidgetConfig>();

  // === Services ===
  private logger = inject(LoggerService).child({ source: 'WidgetContainer' });

  // === State ===
  state = signal<WidgetState>({
    widgetId: '',
    loading: false,
    ready: false,
    error: null
  });

  // === Computed ===
  componentInputs = computed(() => {
    const config = this.widgetConfig();
    return {
      widgetId: config.id,
      widgetConfig: config,
      // Additional inputs can be passed here from config
    };
  });

  constructor() {
    // Error boundary setup
    effect(() => {
      const config = this.widgetConfig();
      this.state.update(s => ({
        ...s,
        widgetId: config.id
      }));
    });
  }

  handleRetry(): void {
    this.state.update(s => ({ ...s, error: null, loading: true }));
    // Retry logic
  }

  // API for child widgets to report status
  onWidgetReady(): void {
    this.state.update(s => ({ ...s, ready: true, loading: false }));
  }

  onWidgetError(error: Error): void {
    this.logger.error(`Widget ${this.widgetConfig().id} error`, error);
    this.state.update(s => ({ ...s, error, loading: false }));
  }
}

Key Decisions:


BaseWidgetComponent (Optional)

Responsibility: Optional base class with helper methods

@Component({
  standalone: true,
  template: ''
})
export abstract class BaseWidgetComponent implements WidgetLifecycle, OnInit, OnDestroy {
  // === Inputs (common to all widgets) ===
  widgetId = input.required<string>();
  widgetConfig = input<ResolvedWidgetConfig>();

  // === Outputs (common events) ===
  widgetError = output<Error>();
  widgetReady = output<void>();

  // === Services (injected) ===
  protected eventBus = inject(EventBusService);
  protected logger = inject(LoggerService);
  protected destroyRef = inject(DestroyRef);

  // === State (common signals) ===
  protected loading = signal(false);
  protected error = signal<Error | null>(null);

  // === Lifecycle ===
  ngOnInit(): void {
    this.logger.debug(`Widget ${this.widgetId()} initializing`);
    this.onWidgetInit();
  }

  ngOnDestroy(): void {
    this.logger.debug(`Widget ${this.widgetId()} destroying`);
    this.onWidgetDestroy();
  }

  // === Abstract Methods (widgets implement) ===
  abstract onWidgetInit(): void;
  abstract onWidgetDestroy(): void;

  // === Helper Methods ===
  protected emitWidgetEvent<T = unknown>(eventType: string, data?: T): void {
    this.eventBus.emit({
      type: `widget.${eventType}`,
      payload: data,
      metadata: {
        widgetId: this.widgetId(),
        timestamp: Date.now()
      }
    });
  }

  protected subscribeToWidgetEvent<T = unknown>(
    eventType: string,
    handler: (data: T) => void
  ): void {
    this.eventBus.on(`widget.${eventType}`)
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(event => handler(event.payload as T));
  }

  protected reportError(error: Error): void {
    this.error.set(error);
    this.widgetError.emit(error);
    this.logger.error(`Widget ${this.widgetId()} error`, error);
  }

  protected markReady(): void {
    this.widgetReady.emit();
    this.logger.debug(`Widget ${this.widgetId()} ready`);
  }
}

Key Decisions:


🔌 Service Architecture

WidgetLayoutService

Responsibility: Grid layout calculations and validation

@Injectable({ providedIn: 'root' })
export class WidgetLayoutService {
  private logger = inject(LoggerService).child({ source: 'WidgetLayoutService' });

  /**
   * Validates widget placement against grid configuration
   */
  validatePlacement(
    placement: ResolvedWidgetConfig['position'],
    gridConfig: GridConfig
  ): boolean {
    const { x, y, width, height } = placement;

    // Basic validation
    if (x < 0 || y < 0 || width <= 0 || height <= 0) {
      return false;
    }

    // Validate doesn't exceed grid columns
    if (x + width > gridConfig.columns) {
      this.logger.warn('Widget placement exceeds grid columns', {
        placement,
        gridColumns: gridConfig.columns
      });
      return false;
    }

    return true;
  }

  /**
   * Calculates grid positioning styles
   */
  calculateGridStyles(position: ResolvedWidgetConfig['position']): {
    gridColumn: string;
    gridRow: string;
  } {
    return {
      gridColumn: `${position.x + 1} / span ${position.width}`,
      gridRow: `${position.y + 1} / span ${position.height}`
    };
  }

  /**
   * Detects overlapping widgets
   */
  detectOverlaps(widgets: ResolvedWidgetConfig[]): boolean {
    // Implementation for overlap detection
    // Useful for drag-and-drop validation (post-MVP)
    return false;
  }
}

WidgetCommunicationService

Responsibility: Thin wrapper over EventBusService for widget events

@Injectable({ providedIn: 'root' })
export class WidgetCommunicationService {
  private eventBus = inject(EventBusService);
  private logger = inject(LoggerService).child({ source: 'WidgetCommunication' });

  /**
   * Emit event from a widget
   */
  emitFromWidget<T = unknown>(
    widgetId: string,
    eventType: string,
    data?: T
  ): void {
    this.eventBus.emit({
      type: `widget.${eventType}`,
      payload: data,
      metadata: {
        source: 'widget',
        widgetId,
        timestamp: Date.now()
      }
    });

    this.logger.debug('Widget event emitted', {
      widgetId,
      eventType,
      hasData: !!data
    });
  }

  /**
   * Subscribe to widget events
   */
  onWidgetEvent<T = unknown>(
    eventType: string,
    handler: (data: T, widgetId: string) => void
  ): Observable<void> {
    return this.eventBus.on(`widget.${eventType}`).pipe(
      map(event => {
        handler(
          event.payload as T,
          event.metadata?.widgetId as string
        );
      })
    );
  }

  /**
   * Subscribe to events from specific widget
   */
  onWidgetEventFrom<T = unknown>(
    widgetId: string,
    eventType: string,
    handler: (data: T) => void
  ): Observable<void> {
    return this.eventBus.on(`widget.${eventType}`).pipe(
      filter(event => event.metadata?.widgetId === widgetId),
      map(event => handler(event.payload as T))
    );
  }
}

Key Decisions:


📐 Data Models

GridConfig

/**
 * Grid layout configuration
 */
export interface GridConfig {
  /** Number of columns in the grid */
  columns: number;

  /** Gap between widgets (px) */
  gap: number;

  /** Row height (px or 'auto') */
  rowHeight: number | 'auto';

  /** Responsive breakpoint configurations */
  breakpoints: {
    mobile: GridBreakpointConfig;
    tablet: GridBreakpointConfig;
    desktop: GridBreakpointConfig;
  };
}

export interface GridBreakpointConfig {
  /** Number of columns for this breakpoint */
  columns: number;

  /** Gap for this breakpoint (px) */
  gap: number;

  /** Row height for this breakpoint */
  rowHeight: number | 'auto';
}

/** Default grid configuration */
export const DEFAULT_GRID_CONFIG: GridConfig = {
  columns: 12,
  gap: 16,
  rowHeight: 100,
  breakpoints: {
    mobile: { columns: 1, gap: 8, rowHeight: 'auto' },
    tablet: { columns: 6, gap: 12, rowHeight: 100 },
    desktop: { columns: 12, gap: 16, rowHeight: 100 }
  }
};

WidgetState

/**
 * Runtime state of a widget instance
 */
export interface WidgetState {
  /** Widget ID */
  widgetId: string;

  /** Loading state */
  loading: boolean;

  /** Error if exists */
  error: Error | null;

  /** Widget ready state */
  ready: boolean;

  /** Optional widget-specific data */
  data?: unknown;
}

WidgetLifecycle

/**
 * Widget lifecycle hooks interface
 */
export interface WidgetLifecycle {
  /**
   * Called when widget initializes
   */
  onWidgetInit(): void;

  /**
   * Called when widget destroys
   */
  onWidgetDestroy(): void;

  /**
   * Called when widget config changes (optional)
   */
  onWidgetConfigChange?(config: ResolvedWidgetConfig): void;

  /**
   * Called when widget should refresh data (optional)
   */
  onWidgetRefresh?(): void;
}

🔄 Integration Patterns

Pattern 1: Dashboard Integration

// dashboard.component.ts
@Component({
  selector: 'app-dashboard',
  standalone: true,
  imports: [WidgetGridComponent],
  template: `
    <lib-gs-widget-grid
      [widgets]="enabledWidgets()"
      [gridConfig]="gridConfig" />
  `
})
export class DashboardComponent {
  private appConfig = inject(AppConfigService);

  gridConfig: GridConfig = DEFAULT_GRID_CONFIG;

  // Consumes existing AppConfigService
  enabledWidgets = computed(() =>
    this.appConfig.dashboardConfig()?.widgets.filter(w => w.enabled) ?? []
  );
}

Integration Points:


Pattern 2: Widget Creation (Extending BaseWidgetComponent)

// my-widget.component.ts
@Component({
  selector: 'my-widget',
  standalone: true,
  template: `<div>My Widget Content</div>`
})
export class MyWidgetComponent extends BaseWidgetComponent {
  // Widget-specific inputs
  customInput = input<string>();

  // Widget-specific state
  data = signal<MyData[]>([]);

  onWidgetInit(): void {
    // Subscribe to events
    this.subscribeToWidgetEvent('data.updated', (newData) => {
      this.data.set(newData);
    });

    // Load initial data
    this.loadData();
  }

  onWidgetDestroy(): void {
    // Cleanup handled by DestroyRef
  }

  private loadData(): void {
    // Data loading logic
  }
}

Integration Points:


Pattern 3: Widget Registration (No Changes)

// module manifest (UNCHANGED)
@ModuleDecorator({
  id: 'my-module',
  // ...
})
export class MyModule implements BrowserModuleContract {
  readonly manifest: ModuleManifest = {
    // ...
    embeddableComponents: [
      {
        id: 'my-widget',
        componentType: MyWidgetComponent,
        displayName: 'My Widget',
        category: 'widget',
        permissions: ['my-module:read']
      }
    ]
  };
}

Integration Points:


🎨 Styling Strategy

Design Tokens Integration

// widget-grid.component.scss
.widget-grid {
  display: grid;
  grid-template-columns: repeat(var(--grid-columns), 1fr);
  gap: var(--grid-gap);
  grid-auto-rows: var(--row-height);
  padding: var(--spacing-md); // from ui-framework
}
// widget-container.component.scss
.widget-container {
  background: var(--color-surface); // from ui-framework
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  padding: var(--spacing-md);
  box-shadow: var(--shadow-sm);

  &.widget-loading {
    opacity: 0.7;
  }

  &.widget-error {
    border-color: var(--color-error);
  }
}

Key Decisions:


🧪 Testing Strategy

Unit Testing

// widget-grid.component.spec.ts
describe('WidgetGridComponent', () => {
  it('should render enabled widgets only', () => {
    const mockWidgets: ResolvedWidgetConfig[] = [
      { id: 'w1', enabled: true, /* ... */ },
      { id: 'w2', enabled: false, /* ... */ }
    ];

    component.widgets = signal(mockWidgets);
    fixture.detectChanges();

    expect(component.enabledWidgets().length).toBe(1);
  });

  it('should calculate grid styles correctly', () => {
    const position = { x: 0, y: 0, width: 4, height: 2 };
    const column = component.getGridColumn(position);
    const row = component.getGridRow(position);

    expect(column).toBe('1 / span 4');
    expect(row).toBe('1 / span 2');
  });
});

Integration Testing

// widget-grid.integration.spec.ts
describe('WidgetGrid Integration', () => {
  it('should integrate with AppConfigService', () => {
    const mockConfig: ResolvedDashboardConfig = {
      columns: 12,
      widgets: [/* mock widgets */],
      availableWidgets: []
    };

    appConfigService.dashboardConfig.set(mockConfig);

    const widgets = component.enabledWidgets();
    expect(widgets).toBeDefined();
  });
});

🚀 Performance Considerations

OnPush Change Detection

All components use ChangeDetectionStrategy.OnPush:

Lazy Loading (Post-MVP)

// Future implementation
async loadWidgetComponent(widgetTypeId: string): Promise<Type<unknown>> {
  const module = await import(`./widgets/${widgetTypeId}.component`);
  return module.WidgetComponent;
}

Memory Management


📊 Deployment Considerations

Library Build

// project.json
{
  "name": "widget-system",
  "targets": {
    "build": {
      "executor": "@nx/angular:ng-packagr-lite",
      "options": {
        "project": "libs/core/widget-system/ng-package.json"
      }
    }
  }
}

Barrel Exports

// src/index.ts
export * from './lib/components/widget-grid.component';
export * from './lib/components/widget-container.component';
export * from './lib/base/base-widget.component';
export * from './lib/services/widget-layout.service';
export * from './lib/models/grid-config.interface';

📱 Responsive Positioning Strategy

Problem Statement

The current ResolvedWidgetConfig.position system uses fixed grid coordinates:

interface ResolvedWidgetConfig {
  position: {
    x: number;      // Grid column start (0-indexed)
    y: number;      // Grid row start (0-indexed)
    width: number;  // Column span
    height: number; // Row span
  };
}

Challenge: A widget configured as { x: 0, y: 0, width: 4, height: 2 } works perfectly on desktop (12 columns), but on mobile (1 column) it would overflow horizontally since width: 4 exceeds available columns.

Two-Phase Solution

Phase 1: MVP - Auto-Shrink Logic (No Config Changes)

Goal: Make existing widget configurations responsive without requiring any changes to ResolvedWidgetConfig.

Implementation: Add intelligent positioning logic in WidgetGridComponent:

// widget-grid.component.ts
export class WidgetGridComponent {
  // ... existing code ...

  /**
   * Applies responsive positioning based on current breakpoint
   * WITHOUT requiring changes to ResolvedWidgetConfig
   */
  private applyResponsivePositioning(
    widget: ResolvedWidgetConfig
  ): WidgetPosition {
    const bp = this.currentBreakpoint();
    const gridCols = this.gridColumns();
    const basePos = widget.position;

    switch (bp) {
      case 'mobile':
        // Mobile: Stack widgets vertically (1 column)
        return {
          x: 0,
          y: basePos.y,
          width: 1,
          height: basePos.height
        };

      case 'tablet':
        // Tablet: Shrink to fit if needed
        return {
          ...basePos,
          width: Math.min(basePos.width, gridCols),
          x: Math.min(basePos.x, gridCols - 1)
        };

      default: // desktop
        // Desktop: Use original position
        return basePos;
    }
  }

  getGridColumn(position: ResolvedWidgetConfig['position']): string {
    const responsive = this.applyResponsivePositioning({ position } as ResolvedWidgetConfig);
    return `${responsive.x + 1} / span ${responsive.width}`;
  }

  getGridRow(position: ResolvedWidgetConfig['position']): string {
    const responsive = this.applyResponsivePositioning({ position } as ResolvedWidgetConfig);
    return `${responsive.y + 1} / span ${responsive.height}`;
  }
}

Auto-Shrink Rules:

Breakpoint Columns Behavior
Mobile 1 Force all widgets to width: 1, stack vertically, preserve height
Tablet 6 Shrink width to fit if exceeds 6, adjust x if out of bounds
Desktop 12 Use original position as-is

Benefits:

Example:

// Widget configured in DB/config:
const widget: ResolvedWidgetConfig = {
  id: 'stats-widget',
  position: { x: 0, y: 0, width: 4, height: 2 },
  // ... other fields
};

// Rendered positions by breakpoint:
// Desktop (12 cols): x=0, y=0, width=4, height=2  ← Original
// Tablet (6 cols):   x=0, y=0, width=4, height=2  ← Fits, no change
// Mobile (1 col):    x=0, y=0, width=1, height=2  ← Auto-shrunk

Phase 2: Post-MVP - Optional Responsive Overrides

Goal: Allow fine-grained control for specific widgets that need custom responsive behavior.

Design: Extend ResolvedWidgetConfig.position with optional responsive overrides:

// NEW: Enhanced position interface (Post-MVP)
interface WidgetPosition {
  /** Base position (desktop default) */
  x: number;
  y: number;
  width: number;
  height: number;

  /** Optional responsive overrides */
  responsive?: {
    mobile?: Partial<Omit<WidgetPosition, 'responsive'>>;
    tablet?: Partial<Omit<WidgetPosition, 'responsive'>>;
  };
}

// Updated ResolvedWidgetConfig
interface ResolvedWidgetConfig {
  // ... existing fields ...
  position: WidgetPosition; // Now supports responsive overrides
}

Implementation:

// widget-grid.component.ts (Post-MVP)
private applyResponsivePositioning(
  widget: ResolvedWidgetConfig
): WidgetPosition {
  const bp = this.currentBreakpoint();
  const gridCols = this.gridColumns();
  const basePos = widget.position;

  // 1. Check for explicit responsive overrides
  if (basePos.responsive?.[bp]) {
    return {
      ...basePos,
      ...basePos.responsive[bp]
    };
  }

  // 2. Fallback to auto-shrink logic (Phase 1)
  switch (bp) {
    case 'mobile':
      return { x: 0, y: basePos.y, width: 1, height: basePos.height };
    case 'tablet':
      return {
        ...basePos,
        width: Math.min(basePos.width, gridCols),
        x: Math.min(basePos.x, gridCols - 1)
      };
    default:
      return basePos;
  }
}

Usage Examples:

// Example 1: Widget that needs custom mobile layout
const customWidget: ResolvedWidgetConfig = {
  id: 'chart-widget',
  position: {
    x: 0,
    y: 0,
    width: 6,
    height: 3,
    responsive: {
      mobile: {
        width: 1,
        height: 4 // Taller on mobile for better chart visibility
      }
    }
  }
};

// Example 2: Widget that needs different tablet positioning
const sidebarWidget: ResolvedWidgetConfig = {
  id: 'sidebar-widget',
  position: {
    x: 8,
    y: 0,
    width: 4,
    height: 2,
    responsive: {
      tablet: {
        x: 0,  // Move to left on tablet
        width: 3
      },
      mobile: {
        width: 1,
        height: 3
      }
    }
  }
};

// Example 3: Widget using auto-shrink (no overrides needed)
const simpleWidget: ResolvedWidgetConfig = {
  id: 'simple-widget',
  position: { x: 0, y: 0, width: 4, height: 2 }
  // No responsive overrides → uses Phase 1 auto-shrink
};

Benefits:

Migration Path:

  1. MVP Launch: All widgets use auto-shrink (Phase 1)
  2. Identify Issues: Monitor which widgets need custom responsive behavior
  3. Incremental Enhancement: Add responsive overrides only where needed
  4. Bulk Optimization: Eventually optimize all critical widgets

Implementation in WidgetGridComponent

Complete responsive implementation combining both phases:

@Component({
  selector: 'lib-gs-widget-grid',
  standalone: true,
  template: `
    <div class="widget-grid"
         [style.--grid-columns]="gridColumns()"
         [style.--grid-gap]="gridGap()"
         [style.--row-height]="rowHeight()">

      @for (widget of enabledWidgets(); track widget.id) {
        <lib-gs-widget-container
          [widgetConfig]="widget"
          [style.grid-column]="getResponsiveGridColumn(widget)"
          [style.grid-row]="getResponsiveGridRow(widget)" />
      }
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class WidgetGridComponent {
  // ... existing fields ...

  /**
   * Get responsive grid column based on current breakpoint
   */
  getResponsiveGridColumn(widget: ResolvedWidgetConfig): string {
    const pos = this.applyResponsivePositioning(widget);
    return `${pos.x + 1} / span ${pos.width}`;
  }

  /**
   * Get responsive grid row based on current breakpoint
   */
  getResponsiveGridRow(widget: ResolvedWidgetConfig): string {
    const pos = this.applyResponsivePositioning(widget);
    return `${pos.y + 1} / span ${pos.height}`;
  }

  /**
   * Apply responsive positioning with optional overrides
   * Supports both Phase 1 (auto-shrink) and Phase 2 (explicit overrides)
   */
  private applyResponsivePositioning(
    widget: ResolvedWidgetConfig
  ): Required<Omit<WidgetPosition, 'responsive'>> {
    const bp = this.currentBreakpoint();
    const gridCols = this.gridColumns();
    const basePos = widget.position;

    // Phase 2: Check for explicit responsive overrides (if implemented)
    if ('responsive' in basePos && basePos.responsive?.[bp]) {
      return {
        x: basePos.x,
        y: basePos.y,
        width: basePos.width,
        height: basePos.height,
        ...basePos.responsive[bp]
      };
    }

    // Phase 1: Auto-shrink fallback
    switch (bp) {
      case 'mobile':
        return {
          x: 0,
          y: basePos.y,
          width: 1,
          height: basePos.height
        };

      case 'tablet':
        return {
          x: Math.min(basePos.x, gridCols - 1),
          y: basePos.y,
          width: Math.min(basePos.width, gridCols),
          height: basePos.height
        };

      default: // desktop
        return {
          x: basePos.x,
          y: basePos.y,
          width: basePos.width,
          height: basePos.height
        };
    }
  }
}

Validation & Testing

Unit Tests for Responsive Positioning

// widget-grid.component.spec.ts
describe('WidgetGridComponent - Responsive Positioning', () => {
  describe('Phase 1: Auto-Shrink', () => {
    it('should stack widgets vertically on mobile', () => {
      component.currentBreakpoint.set('mobile');
      component.gridConfig.set({
        ...DEFAULT_GRID_CONFIG,
        breakpoints: {
          mobile: { columns: 1, gap: 8, rowHeight: 'auto' }
        }
      });

      const widget: ResolvedWidgetConfig = {
        id: 'w1',
        position: { x: 4, y: 0, width: 4, height: 2 },
        // ... other fields
      };

      const responsive = component['applyResponsivePositioning'](widget);

      expect(responsive.x).toBe(0);
      expect(responsive.width).toBe(1);
      expect(responsive.height).toBe(2); // Preserved
    });

    it('should shrink to fit on tablet if exceeds columns', () => {
      component.currentBreakpoint.set('tablet');
      component.gridColumns.set(6);

      const widget: ResolvedWidgetConfig = {
        id: 'w1',
        position: { x: 0, y: 0, width: 8, height: 2 }, // width > 6
        // ... other fields
      };

      const responsive = component['applyResponsivePositioning'](widget);

      expect(responsive.width).toBe(6); // Shrunk to fit
    });

    it('should use original position on desktop', () => {
      component.currentBreakpoint.set('desktop');
      component.gridColumns.set(12);

      const widget: ResolvedWidgetConfig = {
        id: 'w1',
        position: { x: 4, y: 2, width: 4, height: 2 },
        // ... other fields
      };

      const responsive = component['applyResponsivePositioning'](widget);

      expect(responsive).toEqual({
        x: 4,
        y: 2,
        width: 4,
        height: 2
      });
    });
  });

  describe('Phase 2: Responsive Overrides (Post-MVP)', () => {
    it('should apply mobile override when specified', () => {
      component.currentBreakpoint.set('mobile');

      const widget: ResolvedWidgetConfig = {
        id: 'w1',
        position: {
          x: 0,
          y: 0,
          width: 4,
          height: 2,
          responsive: {
            mobile: {
              width: 1,
              height: 4 // Custom height for mobile
            }
          }
        }
      };

      const responsive = component['applyResponsivePositioning'](widget);

      expect(responsive.width).toBe(1);
      expect(responsive.height).toBe(4); // Override applied
    });

    it('should fallback to auto-shrink if no override for breakpoint', () => {
      component.currentBreakpoint.set('tablet');

      const widget: ResolvedWidgetConfig = {
        id: 'w1',
        position: {
          x: 0,
          y: 0,
          width: 8,
          height: 2,
          responsive: {
            mobile: { width: 1 } // No tablet override
          }
        }
      };

      const responsive = component['applyResponsivePositioning'](widget);

      expect(responsive.width).toBe(6); // Auto-shrink applied
    });
  });
});

Documentation Updates Required

When implementing Phase 2, update:

  1. module-config Types:

    // libs/core/module-config/src/lib/models/resolved-dashboard-config.ts
    export interface WidgetPosition {
      x: number;
      y: number;
      width: number;
      height: number;
      responsive?: {
        mobile?: Partial<Omit<WidgetPosition, 'responsive'>>;
        tablet?: Partial<Omit<WidgetPosition, 'responsive'>>;
      };
    }
    
  2. Database Schema (if persisted):

    • Add responsive field to widget configuration storage
    • Make it optional/nullable for backwards compatibility
  3. UI Customization Interface:

    • Add responsive position controls to tenant customization UI
    • Allow admins to preview responsive behavior

Rollout Strategy

MVP (Phase 1)

✅ Timeline: Initial widget-system release

Post-MVP (Phase 2)

⏳ Timeline: After MVP validation, based on feedback


Performance Considerations

Phase 1 (Auto-Shrink):

Phase 2 (Responsive Overrides):


🔮 Future Enhancements (Post-MVP)

Drag-and-Drop

// Future: WidgetGridComponent with drag-and-drop
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';

handleWidgetDrop(event: CdkDragDrop<ResolvedWidgetConfig[]>): void {
  const widgets = [...this.widgets()];
  moveItemInArray(widgets, event.previousIndex, event.currentIndex);

  // Update positions
  this.updateWidgetPositions(widgets);

  // Emit changes to AppConfigService
  this.saveWidgetLayout(widgets);
}

Widget Resize

// Future: Resize handles
import { CdkDrag } from '@angular/cdk/drag-drop';

onWidgetResize(widgetId: string, newSize: { width: number; height: number }): void {
  // Update widget size
  // Validate against grid
  // Save to config
}

Widget Maximize

// Future: Fullscreen mode
maximizeWidget(widgetId: string): void {
  this.maximizedWidget.set(widgetId);
  // Apply fullscreen styles
  // Escape key to restore
}

✅ Design Validation Checklist


📝 Architecture Decision Records

ADR-001: Use ResolvedWidgetConfig Instead of New Type

Decision: Use existing ResolvedWidgetConfig from @gograb-space/module-config

Rationale:

Alternatives Considered:


ADR-002: CSS Grid Over Alternative Layouts

Decision: Use CSS Grid for widget layout

Rationale:

Alternatives Considered:


ADR-003: Optional BaseWidgetComponent

Decision: BaseWidgetComponent is optional, not required

Rationale:

Alternatives Considered:


ADR-004: Error Boundary Pattern

Decision: WidgetContainerComponent provides error boundaries

Rationale:

Alternatives Considered:


ADR-005: Two-Phase Responsive Positioning

Decision: Implement responsive positioning in two phases: MVP with auto-shrink logic, Post-MVP with optional responsive overrides

Rationale:

Phase 1 (MVP) - Auto-Shrink Logic:

Phase 2 (Post-MVP) - Responsive Overrides:

Benefits:

Alternatives Considered:

  1. Immediate responsive overrides in MVP (Rejected)

    • Would require interface changes upfront
    • More complex MVP implementation
    • Unknown if override complexity is needed
    • Database migrations before validation
  2. Separate position configs per breakpoint (Rejected)

    • Too complex for config management
    • Massive duplication in configuration
    • Hard to maintain consistency
    • Poor UX in admin interface
  3. Auto-shrink only (no Phase 2) (Rejected)

    • Some widgets may need custom responsive behavior
    • Charts, complex layouts may not work well with simple shrink
    • Limits flexibility for future optimization
  4. Percentage-based sizing (Rejected)

    • Doesn't match existing grid column system
    • Complex mental model for admins
    • Breaking change to ResolvedWidgetConfig
    • Harder to reason about positioning

Migration Path:

Performance Impact:

Example:

// MVP: All widgets use auto-shrink
const widget: ResolvedWidgetConfig = {
  position: { x: 0, y: 0, width: 4, height: 2 }
  // Mobile auto-shrinks to width: 1
};

// Post-MVP: Widgets can override when needed
const customWidget: ResolvedWidgetConfig = {
  position: {
    x: 0, y: 0, width: 6, height: 3,
    responsive: {
      mobile: { width: 1, height: 4 } // Custom height for better mobile UX
    }
  }
};