# Phase 8: Admin Dashboard - Research

**Researched:** 2026-02-05
**Domain:** Admin dashboard with customizable widgets, real-time updates, and plugin settings pages
**Confidence:** HIGH (based on WinterCMS reference + official library documentation + CONTEXT.md decisions)

## Summary

This research covers the implementation of a customizable admin dashboard for SummerCMS. The dashboard provides a container for plugin-registered widgets (report widgets) with per-user layout customization, real-time updates via SSE/WebSocket, and a centralized settings system for plugin configuration.

The WinterCMS reference implementation provides a comprehensive blueprint:
1. **ReportContainer** widget hosts multiple ReportWidgetBase instances with YAML-configured defaults
2. **Per-user preferences** stored in a dedicated table with JSON-serialized layouts
3. **WidgetManager** singleton collects widgets from plugin registrations
4. **SettingsManager** centralizes plugin settings with categories and permissions
5. **SettingsModel** behavior provides YAML-driven forms for plugin configuration

The CONTEXT.md decisions specify:
- Masonry/fluid layout with drag-and-drop customization
- Real-time updates via WebSocket/SSE for live data
- Widget configuration (title, sizing, custom parameters)
- WinterCMS-style centralized Settings area grouped by category

**Primary recommendation:** Use Gridstack.js for dashboard grid layout (masonry, drag-and-drop, resize), ZIO HTTP SSE for real-time widget updates, HTMX SSE extension for frontend integration, and the Phase 7 YAML-driven form system for widget configuration and settings pages.

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Gridstack.js | 10.x | Dashboard grid layout | Native drag-drop, resize, save/restore, TypeScript, no deps |
| ZIO HTTP | 3.4.x | SSE/WebSocket endpoints | Already in stack, native streaming support |
| HTMX SSE Extension | 2.2.x | Frontend SSE integration | Declarative, pairs with existing HTMX usage |
| circe-yaml | 1.x | Widget/settings config | Already in stack from Phase 7 |
| ScalaTags | 0.13.x | HTML generation | Already in stack from Phase 7 |

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| Chart.js | 4.x | Widget charts/graphs | Stats widgets with visualizations |
| Flatpickr | 4.6.x | Date range picker | Widget date configuration |

### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Gridstack.js | Muuri | Muuri more flexible but Gridstack specialized for dashboards |
| Gridstack.js | React Grid Layout | React Grid requires React; we use HTMX/ScalaTags |
| SSE | Full WebSocket | SSE simpler, unidirectional is sufficient for dashboard updates |
| Gridstack.js | Packery/Isotope | Packery older, Gridstack actively maintained with TypeScript |

**Mill dependencies:**
```scala
// Phase 7 deps already included (circe-yaml, scalatags, etc.)
// ZIO HTTP already in stack
```

**Frontend (included via CDN or bundled):**
```html
<link href="https://cdn.jsdelivr.net/npm/gridstack@10/dist/gridstack.min.css" rel="stylesheet"/>
<script src="https://cdn.jsdelivr.net/npm/gridstack@10/dist/gridstack-all.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.4"></script>
```

## Architecture Patterns

### Recommended Project Structure
```
summercms/
├── admin/
│   ├── dashboard/
│   │   ├── Dashboard.scala            # Dashboard container
│   │   ├── DashboardWidget.scala      # Widget base trait/class
│   │   ├── WidgetRegistry.scala       # Collects registered widgets
│   │   ├── UserDashboardPrefs.scala   # Per-user layout storage
│   │   ├── widgets/                   # Core widgets
│   │   │   ├── WelcomeWidget.scala
│   │   │   ├── SystemStatusWidget.scala
│   │   │   └── QuickActionsWidget.scala
│   │   └── sse/
│   │       └── WidgetEventStream.scala  # SSE endpoint for real-time updates
│   ├── settings/
│   │   ├── SettingsManager.scala      # Collects registered settings
│   │   ├── SettingsController.scala   # Generic settings CRUD
│   │   ├── SettingsModel.scala        # Base trait for settings models
│   │   └── categories.scala           # Standard category constants
│   └── controllers/
│       └── IndexController.scala      # Dashboard route handler
```

### Pattern 1: Dashboard Widget Base Trait
**What:** Define a base trait for all dashboard (report) widgets
**When to use:** All plugins extending dashboard with widgets
**Example:**
```scala
// Source: WinterCMS ReportWidgetBase pattern + CONTEXT.md
import scalatags.Text.all._
import zio._

trait DashboardWidget:
  /** Unique widget identifier */
  def widgetId: String

  /** Default alias for this widget */
  def defaultAlias: String = widgetId

  /** Widget display name (translation key) */
  def name: String

  /** Widget description (translation key) */
  def description: String

  /** Define configurable properties */
  def defineProperties: Map[String, PropertyDef] = Map(
    "title" -> PropertyDef(
      title = "backend::lang.dashboard.widget_title_label",
      `type` = PropertyType.String,
      default = Some(name)
    ),
    "ocWidgetWidth" -> PropertyDef(
      title = "backend::lang.dashboard.widget_columns_label",
      `type` = PropertyType.Dropdown,
      options = (1 to 12).map(i => i.toString -> s"$i columns").toMap,
      default = Some("6")
    )
  )

  /** Render widget content (can be ZIO effect for async data loading) */
  def render(config: WidgetConfig, ctx: DashboardContext): Task[Frag]

  /** Optional: Stream of updates for real-time widgets */
  def updates(config: WidgetConfig): Option[ZStream[Any, Nothing, WidgetUpdate]] = None

case class PropertyDef(
  title: String,
  `type`: PropertyType,
  description: Option[String] = None,
  default: Option[String] = None,
  options: Map[String, String] = Map.empty,
  required: Boolean = false
)

enum PropertyType:
  case String, Number, Checkbox, Dropdown, DateRange

case class WidgetConfig(
  alias: String,
  properties: Map[String, Any],
  position: WidgetPosition
)

case class WidgetPosition(
  x: Int, y: Int,    // Grid position
  w: Int, h: Int     // Width and height in grid units
)

case class WidgetUpdate(
  widgetAlias: String,
  content: Frag
)
```

### Pattern 2: Widget Registry with Plugin Registration
**What:** Singleton registry collecting widgets from plugins during boot
**When to use:** Plugin system collects all available dashboard widgets
**Example:**
```scala
// Source: WinterCMS WidgetManager pattern
import zio._

trait WidgetRegistry:
  def registerWidget(widgetClass: Class[? <: DashboardWidget], info: WidgetInfo): UIO[Unit]
  def listWidgets: UIO[Map[String, WidgetInfo]]
  def listWidgetsForContext(context: String): UIO[Map[String, WidgetInfo]]
  def resolveWidget(className: String): UIO[Option[DashboardWidget]]

case class WidgetInfo(
  name: String,
  description: String,
  context: List[String] = List("dashboard"),  // Where widget can appear
  permissions: List[String] = Nil              // Required permissions
)

object WidgetRegistry:
  val live: ZLayer[Any, Nothing, WidgetRegistry] =
    ZLayer.fromZIO {
      Ref.make(Map.empty[String, (WidgetInfo, DashboardWidget)]).map { widgetsRef =>
        new WidgetRegistry:
          def registerWidget(widgetClass: Class[? <: DashboardWidget], info: WidgetInfo): UIO[Unit] =
            for
              instance <- ZIO.succeed(widgetClass.getDeclaredConstructor().newInstance())
              _        <- widgetsRef.update(_ + (widgetClass.getName -> (info, instance)))
            yield ()

          def listWidgets: UIO[Map[String, WidgetInfo]] =
            widgetsRef.get.map(_.view.mapValues(_._1).toMap)

          def listWidgetsForContext(context: String): UIO[Map[String, WidgetInfo]] =
            listWidgets.map(_.filter(_._2.context.contains(context)))

          def resolveWidget(className: String): UIO[Option[DashboardWidget]] =
            widgetsRef.get.map(_.get(className).map(_._2))
      }
    }

// Plugin registration in boot
def registerReportWidgets: Map[Class[? <: DashboardWidget], WidgetInfo] = Map(
  classOf[SystemStatusWidget] -> WidgetInfo(
    name = "system::lang.dashboard.status.name",
    description = "system::lang.dashboard.status.description",
    permissions = List("system.access_dashboard")
  ),
  classOf[WelcomeWidget] -> WidgetInfo(
    name = "backend::lang.dashboard.welcome.name",
    description = "backend::lang.dashboard.welcome.description"
  )
)
```

### Pattern 3: Per-User Dashboard Preferences
**What:** Store and retrieve per-user widget layouts using database
**When to use:** Dashboard layout customization per admin user
**Example:**
```scala
// Source: WinterCMS UserPreference + ReportContainer patterns
import io.getquill._
import zio._

case class DashboardPreference(
  userId: Long,
  context: String,         // "dashboard", "analytics", etc.
  widgets: Json             // JSON array of widget configurations
)

case class StoredWidgetConfig(
  className: String,
  alias: String,
  sortOrder: Int,
  configuration: Map[String, Any],  // Including position: {x, y, w, h}
)

trait DashboardPreferenceService:
  def getPreferences(userId: Long, context: String): Task[List[StoredWidgetConfig]]
  def setPreferences(userId: Long, context: String, widgets: List[StoredWidgetConfig]): Task[Unit]
  def resetToDefault(userId: Long, context: String): Task[Unit]
  def getDefaultLayout(context: String): Task[List[StoredWidgetConfig]]

object DashboardPreferenceService:
  def live(quill: Quill.Postgres[SnakeCase]): ZLayer[Any, Nothing, DashboardPreferenceService] =
    ZLayer.succeed {
      import quill._
      new DashboardPreferenceService:
        def getPreferences(userId: Long, context: String): Task[List[StoredWidgetConfig]] =
          // First try user preferences, fall back to system defaults
          run(
            query[DashboardPreference]
              .filter(p => p.userId == lift(userId) && p.context == lift(context))
          ).flatMap {
            case Nil => getDefaultLayout(context)
            case prefs => ZIO.succeed(parseWidgets(prefs.head.widgets))
          }

        def setPreferences(userId: Long, context: String, widgets: List[StoredWidgetConfig]): Task[Unit] =
          run(
            query[DashboardPreference]
              .filter(p => p.userId == lift(userId) && p.context == lift(context))
              .updateValue(lift(DashboardPreference(userId, context, encodeWidgets(widgets))))
          ).unit.catchAll { _ =>
            run(
              query[DashboardPreference]
                .insertValue(lift(DashboardPreference(userId, context, encodeWidgets(widgets))))
            ).unit
          }

        def resetToDefault(userId: Long, context: String): Task[Unit] =
          run(
            query[DashboardPreference]
              .filter(p => p.userId == lift(userId) && p.context == lift(context))
              .delete
          ).unit

        def getDefaultLayout(context: String): Task[List[StoredWidgetConfig]] =
          // Load from YAML config or system parameter
          ZIO.attempt {
            val config = loadYamlConfig(s"config_dashboard_$context.yaml")
            parseDefaultWidgets(config)
          }
    }
```

### Pattern 4: Dashboard Container with Gridstack Integration
**What:** Render dashboard with Gridstack.js grid layout
**When to use:** Main dashboard view rendering
**Example:**
```scala
// Source: WinterCMS ReportContainer + Gridstack.js patterns
import scalatags.Text.all._

def renderDashboard(
  widgets: List[(String, DashboardWidget, WidgetConfig)],
  canAddAndDelete: Boolean
)(using ctx: DashboardContext): Frag = {
  div(cls := "dashboard-container",
    // Toolbar
    div(cls := "dashboard-toolbar",
      canAddAndDelete.option(
        button(cls := "btn btn-primary",
          attr("hx-get") := "/admin/dashboard/add-widget-popup",
          attr("hx-target") := "#modal-container",
          "Add Widget"
        )
      ),
      button(cls := "btn btn-secondary",
        attr("hx-post") := "/admin/dashboard/reset",
        attr("hx-confirm") := "Reset dashboard to default layout?",
        "Reset to Default"
      )
    ),

    // Grid container - Gridstack.js will initialize this
    div(cls := "grid-stack",
      id := "dashboard-grid",
      widgets.map { case (alias, widget, config) =>
        div(cls := "grid-stack-item",
          attr("gs-id") := alias,
          attr("gs-x") := config.position.x.toString,
          attr("gs-y") := config.position.y.toString,
          attr("gs-w") := config.position.w.toString,
          attr("gs-h") := config.position.h.toString,
          attr("gs-min-w") := "2",
          attr("gs-min-h") := "2",

          div(cls := "grid-stack-item-content widget-container",
            // SSE connection for real-time updates
            widget.updates(config).map { _ =>
              Seq(
                attr("hx-ext") := "sse",
                attr("sse-connect") := s"/admin/dashboard/widget-stream/$alias",
                attr("sse-swap") := "content"
              )
            }.getOrElse(Nil),

            // Widget header
            div(cls := "widget-header",
              span(cls := "widget-title", config.properties.getOrElse("title", widget.name).toString),
              div(cls := "widget-controls",
                button(cls := "btn-widget-config",
                  attr("hx-get") := s"/admin/dashboard/widget-config/$alias",
                  attr("hx-target") := "#modal-container",
                  i(cls := "icon-cog")
                ),
                canAddAndDelete.option(
                  button(cls := "btn-widget-remove",
                    attr("hx-post") := s"/admin/dashboard/remove-widget/$alias",
                    attr("hx-confirm") := "Remove this widget?",
                    i(cls := "icon-trash")
                  )
                )
              )
            ),

            // Widget body (rendered content)
            div(cls := "widget-body", id := s"widget-content-$alias",
              // Initial render - will be swapped via SSE for real-time widgets
              renderWidgetContent(widget, config, ctx)
            )
          )
        )
      }
    ),

    // Gridstack initialization script
    script(raw("""
      document.addEventListener('DOMContentLoaded', function() {
        var grid = GridStack.init({
          cellHeight: 80,
          margin: 10,
          float: true,
          removable: false
        });

        // Save layout on change
        grid.on('change', function(event, items) {
          var layout = items.map(function(item) {
            return {
              id: item.id,
              x: item.x, y: item.y,
              w: item.w, h: item.h
            };
          });

          htmx.ajax('POST', '/admin/dashboard/save-layout', {
            values: { layout: JSON.stringify(layout) }
          });
        });
      });
    """))
  )
}
```

### Pattern 5: SSE Endpoint for Real-Time Widget Updates
**What:** ZIO HTTP SSE endpoint streaming widget updates
**When to use:** Widgets requiring live data (stats, activity feeds)
**Example:**
```scala
// Source: ZIO HTTP SSE documentation + HTMX SSE extension
import zio._
import zio.http._
import zio.stream._

object WidgetStreamEndpoint:
  def routes: Routes[WidgetRegistry & DashboardPreferenceService, Nothing] =
    Routes(
      Method.GET / "admin" / "dashboard" / "widget-stream" / string("alias") ->
        handler { (alias: String, req: Request) =>
          for
            registry <- ZIO.service[WidgetRegistry]
            prefs    <- ZIO.service[DashboardPreferenceService]
            userId   <- extractUserId(req)
            configs  <- prefs.getPreferences(userId, "dashboard")
            config   <- ZIO.fromOption(configs.find(_.alias == alias))
                          .orElseFail(Response.notFound)
            widget   <- registry.resolveWidget(config.className)
                          .someOrFail(Response.notFound)

            stream = widget.updates(toWidgetConfig(config)) match
              case Some(updateStream) =>
                updateStream.map { update =>
                  ServerSentEvent(
                    data = Some(update.content.render),
                    eventType = Some("content"),
                    id = Some(java.util.UUID.randomUUID().toString)
                  )
                }
              case None =>
                // No updates - just keep connection alive with heartbeat
                ZStream.tick(30.seconds).map { _ =>
                  ServerSentEvent(
                    data = Some(":heartbeat"),
                    eventType = Some("heartbeat")
                  )
                }
          yield Response(
            status = Status.Ok,
            headers = Headers(
              Header.ContentType(MediaType.text.`event-stream`),
              Header.CacheControl.NoCache
            ),
            body = Body.fromCharSequenceStreamChunked(
              stream.map(sse => sse.encode)
            )
          )
        }
    )

// Example widget with real-time updates
class ActiveUsersWidget extends DashboardWidget:
  def widgetId = "active-users"
  def name = "backend::lang.dashboard.active_users"
  def description = "Shows currently active users"

  def render(config: WidgetConfig, ctx: DashboardContext): Task[Frag] =
    getActiveUserCount.map { count =>
      div(cls := "stat-widget",
        span(cls := "stat-value", count.toString),
        span(cls := "stat-label", "Active Users")
      )
    }

  override def updates(config: WidgetConfig): Option[ZStream[Any, Nothing, WidgetUpdate]] =
    Some(
      ZStream.repeatZIOWithSchedule(
        getActiveUserCount.map { count =>
          WidgetUpdate(
            config.alias,
            div(cls := "stat-widget",
              span(cls := "stat-value", count.toString),
              span(cls := "stat-label", "Active Users")
            )
          )
        }.orDie,
        Schedule.fixed(5.seconds)
      )
    )
```

### Pattern 6: Settings Manager for Plugin Settings
**What:** Centralized registry of plugin settings pages with categories
**When to use:** Plugins registering configuration pages
**Example:**
```scala
// Source: WinterCMS SettingsManager pattern
import zio._

// Standard categories (translation keys)
object SettingsCategory:
  val System = "system::lang.system.categories.system"
  val CMS = "system::lang.system.categories.cms"
  val Users = "system::lang.system.categories.users"
  val Mail = "system::lang.system.categories.mail"
  val Misc = "system::lang.system.categories.misc"

case class SettingsItem(
  code: String,
  owner: String,              // Plugin ID: "golem15.blog"
  label: String,              // Translation key
  description: Option[String],
  category: String,           // SettingsCategory constant
  icon: Option[String],
  url: Option[String],        // Custom URL, or auto-generated
  settingsClass: Option[String],  // Settings model class name
  permissions: List[String],
  order: Int = 500,
  keywords: Option[String] = None  // For search
)

trait SettingsManager:
  def registerSettings(owner: String, items: Map[String, SettingsItem]): UIO[Unit]
  def listItems(context: Option[String] = None): UIO[Map[String, List[SettingsItem]]]
  def findItem(owner: String, code: String): UIO[Option[SettingsItem]]
  def setContext(owner: String, code: String): UIO[Unit]
  def getContext: UIO[(String, String)]

object SettingsManager:
  val live: ZLayer[Any, Nothing, SettingsManager] =
    ZLayer.fromZIO {
      for
        itemsRef   <- Ref.make(Map.empty[String, SettingsItem])
        contextRef <- Ref.make(("", ""))
      yield new SettingsManager:
        def registerSettings(owner: String, items: Map[String, SettingsItem]): UIO[Unit] =
          itemsRef.update { current =>
            items.foldLeft(current) { case (acc, (code, item)) =>
              val key = s"${owner.toUpperCase}.$${code.toUpperCase}"
              val itemWithOwner = item.copy(
                code = code,
                owner = owner,
                url = item.url.orElse(Some(generateSettingsUrl(owner, code)))
              )
              acc + (key -> itemWithOwner)
            }
          }

        def listItems(context: Option[String]): UIO[Map[String, List[SettingsItem]]] =
          itemsRef.get.map { items =>
            items.values
              .groupBy(_.category)
              .view.mapValues(_.toList.sortBy(_.order))
              .toMap
          }

        def findItem(owner: String, code: String): UIO[Option[SettingsItem]] =
          itemsRef.get.map(_.get(s"${owner.toUpperCase}.${code.toUpperCase}"))

        def setContext(owner: String, code: String): UIO[Unit] =
          contextRef.set((owner.toLowerCase, code.toLowerCase))

        def getContext: UIO[(String, String)] =
          contextRef.get

        private def generateSettingsUrl(owner: String, code: String): String =
          val parts = owner.split('.')
          s"/admin/system/settings/update/${parts.mkString("/")}/$code"
    }

// Plugin registration
def registerSettings: Map[String, SettingsItem] = Map(
  "blog" -> SettingsItem(
    code = "blog",
    owner = "golem15.blog",
    label = "golem15.blog::lang.settings.label",
    description = Some("golem15.blog::lang.settings.description"),
    category = SettingsCategory.CMS,
    icon = Some("icon-pencil"),
    settingsClass = Some("golem15.blog.models.Settings"),
    permissions = List("golem15.blog.manage_settings"),
    order = 500,
    keywords = Some("blog post category")
  )
)
```

### Pattern 7: Settings Model Behavior
**What:** Model behavior for YAML-driven settings storage
**When to use:** Plugin settings models stored in system_settings table
**Example:**
```scala
// Source: WinterCMS SettingsModel behavior
import zio._
import io.circe.Json

trait SettingsModel:
  def settingsCode: String
  def settingsFields: String   // Path to fields.yaml

  // Mixin implementation
  private var fieldValues: Map[String, Any] = Map.empty
  private var instance: Option[SettingsModel] = None

  /** Get singleton instance (loads from DB or creates default) */
  def getInstance: Task[SettingsModel] =
    instance match
      case Some(inst) => ZIO.succeed(inst)
      case None =>
        loadFromDatabase.flatMap {
          case Some(record) =>
            ZIO.succeed {
              fieldValues = record.values
              instance = Some(this)
              this
            }
          case None =>
            ZIO.succeed {
              initSettingsData()
              instance = Some(this)
              this
            }
        }

  /** Override to set default values */
  def initSettingsData(): Unit = ()

  /** Get a setting value */
  def get[T](key: String, default: T): T =
    fieldValues.get(key).map(_.asInstanceOf[T]).getOrElse(default)

  /** Set setting values */
  def set(values: Map[String, Any]): Task[Unit] =
    ZIO.attempt {
      fieldValues = fieldValues ++ values
    } *> saveToDatabase

  /** Reset to defaults */
  def resetDefault: Task[Unit] =
    deleteFromDatabase *> ZIO.succeed {
      fieldValues = Map.empty
      instance = None
    }

  // Database operations (using system_settings table)
  private def loadFromDatabase: Task[Option[SettingsRecord]] = ???
  private def saveToDatabase: Task[Unit] = ???
  private def deleteFromDatabase: Task[Unit] = ???

// Example settings model
class BlogSettings extends SettingsModel:
  val settingsCode = "golem15_blog_settings"
  val settingsFields = "fields.yaml"

  override def initSettingsData(): Unit =
    fieldValues = Map(
      "posts_per_page" -> 10,
      "show_author" -> true,
      "excerpt_length" -> 200
    )

// Usage
val postsPerPage = BlogSettings.getInstance.flatMap { settings =>
  ZIO.succeed(settings.get("posts_per_page", 10))
}
```

### Anti-Patterns to Avoid
- **Polling for updates:** Use SSE streams instead of periodic AJAX polling
- **Global layout storage:** Each user must have their own layout preferences
- **Inline widget configuration:** Use YAML-driven property definitions for consistency
- **Blocking widget renders:** Widget render should be ZIO Task, not blocking
- **Shared mutable state:** Use ZIO Ref for widget registry and settings state
- **Direct database queries in widgets:** Use service layer for data access

## Don't Hand-Roll

Problems that look simple but have existing solutions:

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Dashboard grid layout | Custom CSS Grid + JS | Gridstack.js | Drag-drop, resize, save/restore, mobile support |
| Real-time updates | Custom WebSocket protocol | ZIO HTTP SSE + HTMX ext | Standard protocol, automatic reconnection |
| User preferences storage | Session/cookie storage | Database per-user table | Persists across sessions, multi-device |
| Widget property forms | Custom form generation | Phase 7 form system | Consistency, YAML-driven, validation |
| Settings form generation | Custom forms per plugin | SettingsModel behavior | Standardized, YAML-driven, cached |

**Key insight:** Dashboard widgets are deceptively complex. Each widget needs configuration UI, persistence, permissions, and potentially real-time updates. Reuse WinterCMS patterns for the container/widget relationship rather than building ad-hoc.

## Common Pitfalls

### Pitfall 1: SSE Connection Limits
**What goes wrong:** Browser limits concurrent SSE connections (typically 6 per domain)
**Why it happens:** Each widget opens its own SSE connection
**How to avoid:**
- Single SSE connection per dashboard page with multiplexed events
- Use event names to route updates to correct widgets
- Send widget alias in event type: `event: widget-$alias`
**Warning signs:** Later widgets don't receive updates; "ERR_CONNECTION_LIMIT" in browser console

### Pitfall 2: Layout Save Race Conditions
**What goes wrong:** Rapid drag/resize operations cause lost updates
**Why it happens:** Multiple save requests overlap, last one wins
**How to avoid:**
- Debounce save operations (300-500ms after last change)
- Include full layout state in each save (not incremental updates)
- Server acknowledges save, client can retry on failure
**Warning signs:** Layout reverts unexpectedly; saved layout doesn't match visual

### Pitfall 3: Widget Configuration State Loss
**What goes wrong:** Widget config changes lost on page reload
**Why it happens:** Config saved to layout but not persisted to database
**How to avoid:**
- Widget config stored in preferences alongside position
- Config changes trigger preferences save immediately
- Default config comes from widget definition, overrides from preferences
**Warning signs:** "Reset" doesn't restore expected defaults; config resets on browser refresh

### Pitfall 4: Memory Leaks in Real-Time Widgets
**What goes wrong:** Dashboard becomes slow/unresponsive over time
**Why it happens:** SSE event handlers accumulate; old widget content not garbage collected
**How to avoid:**
- HTMX's SSE extension manages this automatically via sse-swap
- Avoid manual JavaScript event listeners without cleanup
- Use `sse-close` attribute for explicit cleanup on navigation
**Warning signs:** Growing memory usage; event handlers stack on widget reconfigure

### Pitfall 5: Settings Cache Invalidation
**What goes wrong:** Changed settings not reflected immediately
**Why it happens:** Settings cached at application level for performance
**How to avoid:**
- Clear cache on save (WinterCMS calls queue:restart)
- Use short TTL for frequently-changed settings
- Provide "clear cache" admin action
**Warning signs:** Changes require restart; inconsistent behavior across requests

### Pitfall 6: Category Translation and Ordering
**What goes wrong:** Settings categories display incorrectly or in wrong order
**Why it happens:** Categories are translation keys, not display strings; no explicit ordering
**How to avoid:**
- Define standard categories with explicit sort order
- Translate category keys at render time, not storage
- Allow plugins to register new categories with order hints
**Warning signs:** Untranslated category keys visible; random category order

## Code Examples

### Complete Dashboard Controller
```scala
// Source: WinterCMS backend/controllers/Index.php + Gridstack patterns
import zio._
import zio.http._
import scalatags.Text.all._

class DashboardController(
  widgetRegistry: WidgetRegistry,
  prefsService: DashboardPreferenceService,
  auth: AuthService
):

  def index: Handler[Any, Nothing, Request, Response] =
    handler { (req: Request) =>
      for
        user    <- auth.getUser(req)
        widgets <- loadUserDashboard(user.id, "dashboard")
        html    <- renderDashboard(widgets, canAddAndDelete = true)
      yield Response.html(html)
    }

  def saveLayout: Handler[Any, Nothing, Request, Response] =
    handler { (req: Request) =>
      for
        user    <- auth.getUser(req)
        layout  <- req.body.asString.flatMap(parseLayoutJson)
        _       <- prefsService.setPreferences(user.id, "dashboard", layout)
      yield Response.ok
    }

  def addWidgetPopup: Handler[Any, Nothing, Request, Response] =
    handler { (req: Request) =>
      for
        user    <- auth.getUser(req)
        widgets <- widgetRegistry.listWidgetsForContext("dashboard")
        // Filter by user permissions
        available = widgets.filter { case (_, info) =>
          info.permissions.isEmpty || user.hasAnyAccess(info.permissions)
        }
      yield Response.html(renderAddWidgetPopup(available))
    }

  def addWidget: Handler[Any, Nothing, Request, Response] =
    handler { (req: Request) =>
      for
        user      <- auth.getUser(req)
        form      <- req.body.asURLEncodedForm
        className <- ZIO.fromOption(form.get("className"))
        size      <- ZIO.fromOption(form.get("size").flatMap(_.toIntOption))
        widget    <- widgetRegistry.resolveWidget(className)
                       .someOrFail(Response.badRequest("Invalid widget class"))

        // Get current layout to determine new widget position
        current <- prefsService.getPreferences(user.id, "dashboard")
        nextOrder = current.map(_.sortOrder).maxOption.getOrElse(0) + 1
        alias = s"widget_${user.id}_$nextOrder"

        newWidget = StoredWidgetConfig(
          className = className,
          alias = alias,
          sortOrder = nextOrder,
          configuration = Map(
            "ocWidgetWidth" -> size,
            "x" -> 0, "y" -> current.size,  // Add at bottom
            "w" -> size, "h" -> 2
          )
        )

        _ <- prefsService.setPreferences(user.id, "dashboard", current :+ newWidget)

        // Return partial HTML for the new widget
        config = toWidgetConfig(newWidget)
        html <- widget.render(config, DashboardContext.forUser(user))
      yield Response.html(renderGridstackItem(alias, widget, config, html))
    }

  def removeWidget: Handler[Any, Nothing, Request, Response] =
    handler { (req: Request) =>
      for
        user   <- auth.getUser(req)
        alias  <- req.url.queryParams.get("alias").flatMap(_.headOption)
                    .fold(ZIO.fail(Response.badRequest("Missing alias")))(ZIO.succeed)
        current <- prefsService.getPreferences(user.id, "dashboard")
        updated = current.filterNot(_.alias == alias)
        _       <- prefsService.setPreferences(user.id, "dashboard", updated)
      yield Response.ok
    }

  def resetDashboard: Handler[Any, Nothing, Request, Response] =
    handler { (req: Request) =>
      for
        user <- auth.getUser(req)
        _    <- prefsService.resetToDefault(user.id, "dashboard")
      yield Response.redirect("/admin")
    }
```

### Settings Controller with Generic CRUD
```scala
// Source: WinterCMS system/controllers/Settings.php
import zio._
import zio.http._

class SettingsController(
  settingsManager: SettingsManager,
  formRenderer: FormRenderer,
  auth: AuthService
):

  def index: Handler[Any, Nothing, Request, Response] =
    handler { (req: Request) =>
      for
        user      <- auth.getUser(req)
        items     <- settingsManager.listItems()
        // Filter by permissions
        filtered = items.view.mapValues(_.filter { item =>
          item.permissions.isEmpty || user.hasAnyAccess(item.permissions)
        }).filterNot(_._2.isEmpty).toMap
      yield Response.html(renderSettingsIndex(filtered))
    }

  def update(author: String, plugin: String, code: String): Handler[Any, Nothing, Request, Response] =
    handler { (req: Request) =>
      val owner = s"$author.$plugin"
      for
        user <- auth.getUser(req)
        item <- settingsManager.findItem(owner, code)
                  .someOrFail(Response.notFound)
        _    <- ZIO.when(item.permissions.nonEmpty && !user.hasAnyAccess(item.permissions)) {
                  ZIO.fail(Response.forbidden)
                }
        _    <- settingsManager.setContext(owner, code)

        // Load settings model
        model <- loadSettingsModel(item.settingsClass.get)
        instance <- model.getInstance

        // Parse YAML fields definition
        fieldsConfig <- parseFieldsYaml(model.settingsFields)

        // Render form
        form <- formRenderer.render(fieldsConfig, instance.getValues)
      yield Response.html(renderSettingsForm(item, form))
    }

  def save(author: String, plugin: String, code: String): Handler[Any, Nothing, Request, Response] =
    handler { (req: Request) =>
      val owner = s"$author.$plugin"
      for
        user  <- auth.getUser(req)
        item  <- settingsManager.findItem(owner, code)
                   .someOrFail(Response.notFound)
        model <- loadSettingsModel(item.settingsClass.get)
        instance <- model.getInstance

        // Parse form data
        formData <- req.body.asURLEncodedForm
        values = formData.view.mapValues(_.head).toMap

        // Validate (optional - use model validation rules)
        _ <- validateSettings(model, values)

        // Save
        _ <- instance.set(values)

        // Flash success message
      yield Response.redirect(s"/admin/system/settings/update/$author/$plugin/$code")
        .addHeader(Header.Custom("X-Flash", "Settings saved successfully"))
    }
```

### Default Dashboard Configuration YAML
```yaml
# config_dashboard.yaml
# Source: WinterCMS backend/controllers/index/config_dashboard.yaml

defaultWidgets:
  welcome:
    class: summercms.admin.widgets.WelcomeWidget
    sortOrder: 50
    configuration:
      ocWidgetWidth: 7
      x: 0
      y: 0
      w: 7
      h: 3

  systemStatus:
    class: summercms.admin.widgets.SystemStatusWidget
    sortOrder: 60
    configuration:
      ocWidgetWidth: 5
      x: 7
      y: 0
      w: 5
      h: 3

  recentActivity:
    class: summercms.admin.widgets.RecentActivityWidget
    sortOrder: 70
    configuration:
      ocWidgetWidth: 12
      x: 0
      y: 3
      w: 12
      h: 4
      itemCount: 10
```

### Widget Property Configuration Popup
```scala
// Source: WinterCMS ReportContainer property config patterns
def renderWidgetConfigPopup(
  alias: String,
  widget: DashboardWidget,
  currentConfig: WidgetConfig
): Frag = {
  val properties = widget.defineProperties

  div(cls := "modal-dialog",
    div(cls := "modal-content",
      div(cls := "modal-header",
        h5(cls := "modal-title", "Widget Configuration"),
        button(`type` := "button", cls := "btn-close",
          attr("data-bs-dismiss") := "modal")
      ),
      form(
        attr("hx-post") := s"/admin/dashboard/widget-config/$alias",
        attr("hx-target") := s"#widget-content-$alias",
        attr("hx-swap") := "innerHTML",

        div(cls := "modal-body",
          properties.map { case (propName, propDef) =>
            div(cls := "form-group",
              label(cls := "form-label", propDef.title),
              propDef.description.map(d => p(cls := "help-block", d)),
              renderPropertyInput(propName, propDef, currentConfig.properties.get(propName))
            )
          }
        ),

        div(cls := "modal-footer",
          button(`type` := "button", cls := "btn btn-secondary",
            attr("data-bs-dismiss") := "modal", "Cancel"),
          button(`type` := "submit", cls := "btn btn-primary", "Save")
        )
      )
    )
  )
}

def renderPropertyInput(name: String, propDef: PropertyDef, value: Option[Any]): Frag = {
  val currentValue = value.orElse(propDef.default).map(_.toString).getOrElse("")

  propDef.`type` match
    case PropertyType.String =>
      input(cls := "form-control", `type` := "text",
        attr("name") := name, attr("value") := currentValue)

    case PropertyType.Number =>
      input(cls := "form-control", `type` := "number",
        attr("name") := name, attr("value") := currentValue)

    case PropertyType.Checkbox =>
      div(cls := "form-check",
        input(cls := "form-check-input", `type` := "checkbox",
          attr("name") := name,
          (currentValue == "true").option(attr("checked") := "checked"))
      )

    case PropertyType.Dropdown =>
      select(cls := "form-select", attr("name") := name,
        propDef.options.map { case (optValue, optLabel) =>
          option(attr("value") := optValue,
            (currentValue == optValue).option(attr("selected") := "selected"),
            optLabel)
        }
      )

    case PropertyType.DateRange =>
      input(cls := "form-control flatpickr",
        `type` := "text", attr("name") := name, attr("value") := currentValue,
        attr("data-mode") := "range")
}
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| jQuery UI sortable | Gridstack.js | 2020+ | Better mobile support, TypeScript, maintained |
| Polling for updates | SSE/WebSocket | 2018+ | Real-time, lower overhead |
| Session-based prefs | Database per-user | Standard | Persists, multi-device |
| Manual AJAX widget refresh | HTMX SSE extension | 2023+ | Declarative, automatic reconnection |
| PHP serialized layout | JSON layout storage | Standard | Interoperable, queryable |

**Deprecated/outdated:**
- jQuery UI Sortable: Use Gridstack.js for dashboard grids
- Isotope/Packery: Gridstack specifically designed for dashboards
- Long-polling: Use SSE for server-push updates
- Cookie-based preferences: Use database storage for persistence

## Open Questions

1. **Multiplexed SSE vs Multiple Connections**
   - What we know: Browser limits concurrent connections (6 per domain)
   - What's unclear: Best pattern for many real-time widgets
   - Recommendation: Single SSE connection with event routing by widget alias

2. **Widget Caching Strategy**
   - What we know: Some widgets expensive to render (database queries)
   - What's unclear: How to cache widget HTML while maintaining real-time updates
   - Recommendation: Cache initial render, SSE updates bypass cache

3. **Admin-Set Default Layout**
   - What we know: WinterCMS allows admins to "make layout default"
   - What's unclear: Should super-admin override affect existing user layouts?
   - Recommendation: System default separate from user prefs; "reset" restores system default

4. **Widget Asset Loading**
   - What we know: Some widgets need additional JS/CSS (Chart.js)
   - What's unclear: How to lazy-load widget assets only when widget is used
   - Recommendation: Define asset dependencies in widget class, inject on dashboard load

## Sources

### Primary (HIGH confidence)
- WinterCMS reference implementation:
  - `modules/backend/widgets/ReportContainer.php` - Dashboard container pattern
  - `modules/backend/classes/ReportWidgetBase.php` - Widget base class
  - `modules/backend/classes/WidgetManager.php` - Widget registration
  - `modules/system/classes/SettingsManager.php` - Settings registration
  - `modules/system/behaviors/SettingsModel.php` - Settings persistence
  - `modules/backend/models/UserPreference.php` - Per-user storage
- [HTMX SSE Extension](https://htmx.org/extensions/sse/) - SSE frontend integration
- [Gridstack.js](https://gridstackjs.com/) - Dashboard grid layout library
- [ZIO HTTP SSE Examples](https://ziohttp.com/examples/server-sent-events-in-endpoints/) - SSE endpoint implementation

### Secondary (MEDIUM confidence)
- [Designing Scalable Database for User Settings](https://medium.com/@jorgemudry/designing-a-scalable-database-for-user-settings-8e189a678a85) - Preferences schema patterns
- [Building Real-Time Dashboards with FastAPI and HTMX](https://medium.com/codex/building-real-time-dashboards-with-fastapi-and-htmx-01ea458673cb) - SSE dashboard patterns

### Tertiary (LOW confidence)
- WebSearch results on dashboard widget best practices 2025-2026
- Community discussions on SSE vs WebSocket tradeoffs

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH - Gridstack.js docs + ZIO HTTP SSE verified
- Architecture: HIGH - Based on thorough WinterCMS reference analysis
- Pitfalls: MEDIUM - Mix of WinterCMS patterns and web development experience
- Real-time patterns: MEDIUM - ZIO SSE documented, HTMX integration well-established

**Research date:** 2026-02-05
**Valid until:** 2026-03-05 (30 days - stable domain)
