From e0acf7d93aab80c6e1917fa485f94e708f1a19de Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 5 Feb 2026 15:07:51 +0100 Subject: [PATCH] docs(08): research admin dashboard domain Phase 8: Admin Dashboard - Standard stack: Gridstack.js for layout, ZIO HTTP SSE for real-time - Architecture patterns: Widget registry, per-user preferences, SSE streaming - Settings system: Centralized manager, YAML-driven forms - Pitfalls: SSE connection limits, layout save races, cache invalidation --- .../phases/08-admin-dashboard/08-RESEARCH.md | 1099 +++++++++++++++++ 1 file changed, 1099 insertions(+) create mode 100644 .planning/phases/08-admin-dashboard/08-RESEARCH.md diff --git a/.planning/phases/08-admin-dashboard/08-RESEARCH.md b/.planning/phases/08-admin-dashboard/08-RESEARCH.md new file mode 100644 index 0000000..31313f6 --- /dev/null +++ b/.planning/phases/08-admin-dashboard/08-RESEARCH.md @@ -0,0 +1,1099 @@ +# 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 + + + +``` + +## 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)