---
phase: 08-admin-dashboard
plan: 03
type: execute
wave: 2
depends_on: ["08-01"]
files_modified:
  - summercms/admin/src/dashboard/Dashboard.scala
  - summercms/admin/src/dashboard/DashboardController.scala
  - summercms/admin/src/dashboard/widgets/WelcomeWidget.scala
  - summercms/admin/src/dashboard/widgets/SystemStatusWidget.scala
  - summercms/admin/src/dashboard/widgets/QuickActionsWidget.scala
  - summercms/admin/src/dashboard/sse/WidgetEventStream.scala
autonomous: true

must_haves:
  truths:
    - "Admin sees dashboard with Gridstack.js grid after login"
    - "Widgets can be dragged, resized, and layout is saved"
    - "Real-time widgets receive updates via SSE"
    - "Default widgets display on fresh install"
  artifacts:
    - path: "summercms/admin/src/dashboard/Dashboard.scala"
      provides: "Dashboard container with Gridstack.js integration"
      contains: "def renderDashboard"
    - path: "summercms/admin/src/dashboard/DashboardController.scala"
      provides: "HTTP routes for dashboard operations"
      exports: ["DashboardController"]
    - path: "summercms/admin/src/dashboard/sse/WidgetEventStream.scala"
      provides: "SSE endpoint for widget updates"
      contains: "ServerSentEvent"
    - path: "summercms/admin/src/dashboard/widgets/WelcomeWidget.scala"
      provides: "Welcome widget for new users"
      contains: "class WelcomeWidget"
    - path: "summercms/admin/src/dashboard/widgets/SystemStatusWidget.scala"
      provides: "System status display widget"
      contains: "class SystemStatusWidget"
  key_links:
    - from: "DashboardController"
      to: "DashboardPreferenceService"
      via: "loads/saves user preferences"
      pattern: "prefsService\\.(get|set)Preferences"
    - from: "Dashboard.scala"
      to: "WidgetEventStream"
      via: "sse-connect attribute in Gridstack items"
      pattern: "sse-connect.*widget-stream"
    - from: "Dashboard.scala"
      to: "Gridstack.js"
      via: "grid-stack classes and initialization"
      pattern: "grid-stack"
---

<objective>
Build the dashboard container with Gridstack.js grid layout, core widgets (Welcome, SystemStatus, QuickActions), and SSE endpoint for real-time widget updates.

Purpose: Deliver the customizable dashboard admins see after login
Output: Dashboard container, DashboardController, SSE endpoint, 3 core widgets
</objective>

<execution_context>
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
@/home/jin/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/08-admin-dashboard/08-RESEARCH.md

# Depends on 08-01 output
@summercms/admin/src/dashboard/DashboardWidget.scala
@summercms/admin/src/dashboard/WidgetRegistry.scala
@summercms/admin/src/dashboard/DashboardPreferenceService.scala
</context>

<tasks>

<task type="auto">
  <name>Task 1: Dashboard Container and Controller</name>
  <files>
    summercms/admin/src/dashboard/Dashboard.scala
    summercms/admin/src/dashboard/DashboardController.scala
  </files>
  <action>
Create the dashboard container and HTTP routes:

**Dashboard.scala** - Render the dashboard with Gridstack.js:

```scala
package summercms.admin.dashboard

import scalatags.Text.all._
import scalatags.Text.tags2.title as pageTitle
import zio._

object Dashboard:
  /** Render full dashboard page */
  def renderDashboard(
    widgets: List[(String, DashboardWidget, WidgetConfig)],
    canAddAndDelete: Boolean,
    ctx: DashboardContext
  ): Task[Frag] =
    for
      renderedWidgets <- ZIO.foreach(widgets) { case (alias, widget, config) =>
        widget.render(config, ctx).map(content => (alias, widget, config, content))
      }
    yield frag(
      // Toolbar
      div(cls := "dashboard-toolbar mb-3 d-flex gap-2",
        if canAddAndDelete then
          button(cls := "btn btn-primary",
            attr("hx-get") := "/admin/dashboard/add-widget-popup",
            attr("hx-target") := "#modal-container",
            i(cls := "icon-plus me-1"), "Add Widget"
          )
        else frag(),
        button(cls := "btn btn-secondary",
          attr("hx-post") := "/admin/dashboard/reset",
          attr("hx-confirm") := "Reset dashboard to default layout?",
          i(cls := "icon-refresh me-1"), "Reset to Default"
        )
      ),

      // Grid container
      div(cls := "grid-stack", id := "dashboard-grid",
        renderedWidgets.map { case (alias, widget, config, content) =>
          renderGridItem(alias, widget, config, content, canAddAndDelete)
        }
      ),

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

          // Debounce save (500ms after last change)
          var saveTimeout = null;
          grid.on('change', function(event, items) {
            if (saveTimeout) clearTimeout(saveTimeout);
            saveTimeout = setTimeout(function() {
              var layout = items.map(function(item) {
                return {
                  alias: 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) }
              });
            }, 500);
          });
        });
      """))
    )

  /** Render a single grid item */
  private def renderGridItem(
    alias: String,
    widget: DashboardWidget,
    config: WidgetConfig,
    content: Frag,
    canDelete: Boolean
  ): Frag =
    val pos = config.position
    val hasUpdates = widget.updates(config).isDefined

    div(cls := "grid-stack-item",
      attr("gs-id") := alias,
      attr("gs-x") := pos.x.toString,
      attr("gs-y") := pos.y.toString,
      attr("gs-w") := pos.w.toString,
      attr("gs-h") := pos.h.toString,
      attr("gs-min-w") := "2",
      attr("gs-min-h") := "2",

      div(cls := "grid-stack-item-content widget-container card",
        // SSE connection for real-time updates
        if hasUpdates then Seq(
          attr("hx-ext") := "sse",
          attr("sse-connect") := s"/admin/dashboard/widget-stream/$alias",
          attr("sse-swap") := "content"
        ) else Nil,

        // Widget header
        div(cls := "card-header d-flex justify-content-between align-items-center",
          span(cls := "widget-title fw-bold",
            config.properties.get("title").map(_.toString).getOrElse(widget.name)
          ),
          div(cls := "widget-controls",
            button(cls := "btn btn-sm btn-link",
              attr("hx-get") := s"/admin/dashboard/widget-config/$alias",
              attr("hx-target") := "#modal-container",
              attr("title") := "Configure",
              i(cls := "icon-cog")
            ),
            if canDelete then
              button(cls := "btn btn-sm btn-link text-danger",
                attr("hx-post") := s"/admin/dashboard/remove-widget/$alias",
                attr("hx-confirm") := "Remove this widget?",
                attr("title") := "Remove",
                i(cls := "icon-trash")
              )
            else frag()
          )
        ),

        // Widget body
        div(cls := "card-body widget-body", id := s"widget-content-$alias",
          content
        )
      )
    )

  /** Render add widget popup */
  def renderAddWidgetPopup(
    available: Map[String, (WidgetInfo, DashboardWidget)]
  ): Frag =
    div(cls := "modal-dialog modal-lg",
      div(cls := "modal-content",
        div(cls := "modal-header",
          h5(cls := "modal-title", "Add Widget"),
          button(`type` := "button", cls := "btn-close", attr("data-bs-dismiss") := "modal")
        ),
        div(cls := "modal-body",
          div(cls := "row g-3",
            available.toList.sortBy(_._2._1.name).map { case (widgetId, (info, widget)) =>
              div(cls := "col-md-6",
                div(cls := "card h-100 widget-option",
                  attr("hx-post") := s"/admin/dashboard/add-widget",
                  attr("hx-vals") := s"""{"widgetId": "$widgetId"}""",
                  attr("hx-swap") := "none",
                  attr("onclick") := "bootstrap.Modal.getInstance(this.closest('.modal')).hide()",
                  div(cls := "card-body",
                    h6(cls := "card-title", info.name),
                    p(cls := "card-text text-muted small", info.description)
                  )
                )
              )
            }
          )
        )
      )
    )
```

**DashboardController.scala** - HTTP routes:

```scala
package summercms.admin.dashboard

import zio._
import zio.http._
import io.circe._
import io.circe.parser._
import io.circe.syntax._

class DashboardController(
  widgetRegistry: WidgetRegistry,
  prefsService: DashboardPreferenceService
):
  def routes: Routes[Any, Nothing] =
    Routes(
      // Main dashboard page
      Method.GET / "admin" -> handler { (req: Request) =>
        renderIndex(req)
      },

      // Save layout (AJAX)
      Method.POST / "admin" / "dashboard" / "save-layout" -> handler { (req: Request) =>
        saveLayout(req)
      },

      // Add widget popup
      Method.GET / "admin" / "dashboard" / "add-widget-popup" -> handler { (req: Request) =>
        addWidgetPopup(req)
      },

      // Add widget
      Method.POST / "admin" / "dashboard" / "add-widget" -> handler { (req: Request) =>
        addWidget(req)
      },

      // Remove widget
      Method.POST / "admin" / "dashboard" / "remove-widget" / string("alias") -> handler {
        (alias: String, req: Request) => removeWidget(alias, req)
      },

      // Reset to default
      Method.POST / "admin" / "dashboard" / "reset" -> handler { (req: Request) =>
        resetDashboard(req)
      },

      // Widget config popup
      Method.GET / "admin" / "dashboard" / "widget-config" / string("alias") -> handler {
        (alias: String, req: Request) => widgetConfigPopup(alias, req)
      }
    )

  private def renderIndex(req: Request): ZIO[Any, Nothing, Response] =
    (for
      userId <- extractUserId(req)
      ctx = DashboardContext(userId, "Admin", Set.empty) // TODO: real user
      configs <- prefsService.getPreferences(userId, "dashboard")
      widgets <- ZIO.foreach(configs) { stored =>
        widgetRegistry.resolveWidget(stored.widgetId).map { maybeWidget =>
          maybeWidget.map { widget =>
            (stored.alias, widget, toWidgetConfig(stored))
          }
        }
      }.map(_.flatten)
      html <- Dashboard.renderDashboard(widgets, canAddAndDelete = true, ctx)
    yield Response.html(wrapInLayout(html))).catchAll { e =>
      ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.InternalServerError))
    }

  private def saveLayout(req: Request): ZIO[Any, Nothing, Response] =
    (for
      userId <- extractUserId(req)
      body   <- req.body.asString
      form   <- ZIO.fromEither(parseFormData(body))
      layoutJson <- ZIO.fromOption(form.get("layout"))
                      .orElseFail(new RuntimeException("Missing layout"))
      layoutUpdates <- ZIO.fromEither(parse(layoutJson).flatMap(_.as[List[LayoutUpdate]]))
      currentPrefs  <- prefsService.getPreferences(userId, "dashboard")

      // Merge layout updates into current preferences
      updatedPrefs = currentPrefs.map { stored =>
        layoutUpdates.find(_.alias == stored.alias) match
          case Some(update) =>
            stored.copy(configuration = stored.configuration ++ Map(
              "x" -> Json.fromInt(update.x),
              "y" -> Json.fromInt(update.y),
              "w" -> Json.fromInt(update.w),
              "h" -> Json.fromInt(update.h)
            ))
          case None => stored
      }

      _ <- prefsService.setPreferences(userId, "dashboard", updatedPrefs)
    yield Response.ok).catchAll { e =>
      ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.BadRequest))
    }

  private def addWidgetPopup(req: Request): ZIO[Any, Nothing, Response] =
    (for
      available <- widgetRegistry.listWidgetsForContext("dashboard")
      html = Dashboard.renderAddWidgetPopup(available)
    yield Response.html(html.render)).catchAll { e =>
      ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.InternalServerError))
    }

  private def addWidget(req: Request): ZIO[Any, Nothing, Response] =
    (for
      userId <- extractUserId(req)
      body   <- req.body.asString
      form   <- ZIO.fromEither(parseFormData(body))
      widgetId <- ZIO.fromOption(form.get("widgetId"))
                    .orElseFail(new RuntimeException("Missing widgetId"))
      widget <- widgetRegistry.resolveWidget(widgetId)
                  .someOrFail(new RuntimeException(s"Unknown widget: $widgetId"))

      currentPrefs <- prefsService.getPreferences(userId, "dashboard")
      nextOrder = currentPrefs.map(_.sortOrder).maxOption.getOrElse(0) + 10
      alias = s"widget_${System.currentTimeMillis()}"

      newConfig = StoredWidgetConfig(
        widgetId = widgetId,
        alias = alias,
        sortOrder = nextOrder,
        configuration = Map(
          "x" -> Json.fromInt(0),
          "y" -> Json.fromInt(currentPrefs.size),
          "w" -> Json.fromInt(widget.defaultPosition.w),
          "h" -> Json.fromInt(widget.defaultPosition.h)
        )
      )

      _ <- prefsService.setPreferences(userId, "dashboard", currentPrefs :+ newConfig)
    yield Response.ok.addHeader(Header.Custom("HX-Refresh", "true"))).catchAll { e =>
      ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.BadRequest))
    }

  private def removeWidget(alias: String, req: Request): ZIO[Any, Nothing, Response] =
    (for
      userId <- extractUserId(req)
      currentPrefs <- prefsService.getPreferences(userId, "dashboard")
      updatedPrefs = currentPrefs.filterNot(_.alias == alias)
      _ <- prefsService.setPreferences(userId, "dashboard", updatedPrefs)
    yield Response.ok.addHeader(Header.Custom("HX-Refresh", "true"))).catchAll { e =>
      ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.InternalServerError))
    }

  private def resetDashboard(req: Request): ZIO[Any, Nothing, Response] =
    (for
      userId <- extractUserId(req)
      _ <- prefsService.resetToDefault(userId, "dashboard")
    yield Response.redirect(URL.decode("/admin").toOption.get)).catchAll { e =>
      ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.InternalServerError))
    }

  // Helper methods
  private def extractUserId(req: Request): Task[Long] =
    // TODO: Extract from session/JWT. For now, return test user ID
    ZIO.succeed(1L)

  private def toWidgetConfig(stored: StoredWidgetConfig): WidgetConfig =
    WidgetConfig(
      alias = stored.alias,
      properties = stored.configuration.view.mapValues(jsonToAny).toMap,
      position = WidgetPosition(
        x = stored.configuration.get("x").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(0),
        y = stored.configuration.get("y").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(0),
        w = stored.configuration.get("w").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(6),
        h = stored.configuration.get("h").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(2)
      )
    )

  private def jsonToAny(json: Json): Any =
    json.fold(
      jsonNull = null,
      jsonBoolean = identity,
      jsonNumber = n => n.toInt.getOrElse(n.toDouble),
      jsonString = identity,
      jsonArray = _.map(jsonToAny).toList,
      jsonObject = _.toMap.view.mapValues(jsonToAny).toMap
    )

  private def parseFormData(body: String): Either[Throwable, Map[String, String]] =
    Right(body.split("&").flatMap { pair =>
      pair.split("=", 2) match
        case Array(k, v) => Some(java.net.URLDecoder.decode(k, "UTF-8") -> java.net.URLDecoder.decode(v, "UTF-8"))
        case _ => None
    }.toMap)

  private def wrapInLayout(content: Frag): String =
    // TODO: Use proper admin layout from Phase 7
    s"""<!DOCTYPE html>
    <html>
    <head>
      <title>Dashboard - SummerCMS</title>
      <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
      <link href="https://cdn.jsdelivr.net/npm/gridstack@10/dist/gridstack.min.css" rel="stylesheet">
      <style>
        .grid-stack-item-content { overflow: hidden; }
        .widget-option { cursor: pointer; transition: transform 0.2s; }
        .widget-option:hover { transform: scale(1.02); }
      </style>
    </head>
    <body>
      <div class="container-fluid py-4">
        ${content.render}
      </div>
      <div id="modal-container" class="modal fade"></div>
      <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
      <script src="https://cdn.jsdelivr.net/npm/gridstack@10/dist/gridstack-all.min.js"></script>
      <script src="https://unpkg.com/htmx.org@2.0.0"></script>
      <script src="https://unpkg.com/htmx-ext-sse@2.2.4/sse.js"></script>
    </body>
    </html>"""

case class LayoutUpdate(alias: String, x: Int, y: Int, w: Int, h: Int)
object LayoutUpdate:
  given Decoder[LayoutUpdate] = io.circe.generic.semiauto.deriveDecoder
```

Note: extractUserId returns hardcoded 1L for now - will be integrated with Phase 6 auth.
  </action>
  <verify>
    mill summercms.admin.compile succeeds
    Dashboard.renderDashboard generates HTML with grid-stack classes
    DashboardController routes compile
  </verify>
  <done>
    Dashboard.scala renders Gridstack.js grid with widget items
    Gridstack initialization includes debounced save on layout change
    DashboardController handles index, save-layout, add/remove widget, reset
    Layout merges position updates from Gridstack.js into stored preferences
  </done>
</task>

<task type="auto">
  <name>Task 2: Core Widgets</name>
  <files>
    summercms/admin/src/dashboard/widgets/WelcomeWidget.scala
    summercms/admin/src/dashboard/widgets/SystemStatusWidget.scala
    summercms/admin/src/dashboard/widgets/QuickActionsWidget.scala
  </files>
  <action>
Create the default dashboard widgets:

**WelcomeWidget.scala** - Welcome message for admins:

```scala
package summercms.admin.dashboard.widgets

import scalatags.Text.all._
import zio._
import summercms.admin.dashboard._

class WelcomeWidget extends DashboardWidget:
  val widgetId = "welcome"
  val name = "Welcome"
  val description = "Welcome message with getting started tips"

  override def defaultPosition: WidgetPosition = WidgetPosition(0, 0, 7, 3)

  def render(config: WidgetConfig, ctx: DashboardContext): Task[Frag] =
    ZIO.succeed(
      div(cls := "welcome-widget",
        h4(cls := "mb-3", s"Welcome back, ${ctx.userName}!"),
        p(cls := "text-muted",
          "This is your SummerCMS dashboard. Use the widgets below to monitor your site and access quick actions."
        ),
        div(cls := "mt-3",
          h6("Getting Started"),
          ul(cls := "list-unstyled",
            li(cls := "mb-2",
              i(cls := "icon-book me-2"),
              a(href := "/admin/docs", "Read the documentation")
            ),
            li(cls := "mb-2",
              i(cls := "icon-puzzle me-2"),
              a(href := "/admin/system/updates", "Install plugins")
            ),
            li(cls := "mb-2",
              i(cls := "icon-brush me-2"),
              a(href := "/admin/cms/themes", "Customize your theme")
            )
          )
        )
      )
    )
```

**SystemStatusWidget.scala** - System health information:

```scala
package summercms.admin.dashboard.widgets

import scalatags.Text.all._
import zio._
import zio.stream._
import summercms.admin.dashboard._

class SystemStatusWidget extends DashboardWidget:
  val widgetId = "system-status"
  val name = "System Status"
  val description = "Shows system health and resource usage"

  override def defaultPosition: WidgetPosition = WidgetPosition(7, 0, 5, 3)

  override def defineProperties: Map[String, PropertyDef] =
    super.defineProperties ++ Map(
      "showMemory" -> PropertyDef(
        title = "Show Memory Usage",
        `type` = PropertyType.Checkbox,
        default = Some("true")
      )
    )

  def render(config: WidgetConfig, ctx: DashboardContext): Task[Frag] =
    for
      runtime <- ZIO.succeed(Runtime.getRuntime)
      usedMemory = (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024)
      maxMemory = runtime.maxMemory() / (1024 * 1024)
      cpuCount = runtime.availableProcessors()
      javaVersion = System.getProperty("java.version")
      scalaVersion = util.Properties.versionNumberString
    yield div(cls := "system-status-widget",
      div(cls := "row g-3",
        // JVM Info
        div(cls := "col-6",
          div(cls := "stat-card p-2 bg-light rounded",
            div(cls := "stat-value h5 mb-0", s"Java $javaVersion"),
            div(cls := "stat-label small text-muted", "JVM Version")
          )
        ),
        div(cls := "col-6",
          div(cls := "stat-card p-2 bg-light rounded",
            div(cls := "stat-value h5 mb-0", s"$cpuCount"),
            div(cls := "stat-label small text-muted", "CPU Cores")
          )
        ),
        // Memory (if enabled)
        if config.properties.get("showMemory").exists(_ == true) then
          frag(
            div(cls := "col-12 mt-2",
              div(cls := "memory-bar",
                label(cls := "small text-muted", s"Memory: $usedMemory MB / $maxMemory MB"),
                div(cls := "progress mt-1",
                  div(cls := "progress-bar",
                    style := s"width: ${(usedMemory.toDouble / maxMemory * 100).toInt}%",
                    role := "progressbar"
                  )
                )
              )
            )
          )
        else frag()
      )
    )

  // Real-time updates every 30 seconds
  override def updates(config: WidgetConfig): Option[ZStream[Any, Nothing, WidgetUpdate]] =
    Some(
      ZStream.repeatZIOWithSchedule(
        render(config, DashboardContext(0, "", Set.empty)).map { content =>
          WidgetUpdate(config.alias, content)
        }.orDie,
        Schedule.fixed(30.seconds)
      )
    )
```

**QuickActionsWidget.scala** - Common admin actions:

```scala
package summercms.admin.dashboard.widgets

import scalatags.Text.all._
import zio._
import summercms.admin.dashboard._

class QuickActionsWidget extends DashboardWidget:
  val widgetId = "quick-actions"
  val name = "Quick Actions"
  val description = "Shortcuts to common admin tasks"

  override def defaultPosition: WidgetPosition = WidgetPosition(0, 3, 12, 2)

  def render(config: WidgetConfig, ctx: DashboardContext): Task[Frag] =
    ZIO.succeed(
      div(cls := "quick-actions-widget",
        div(cls := "d-flex flex-wrap gap-2",
          actionButton("/admin/cms/pages/create", "icon-file", "New Page"),
          actionButton("/admin/blog/posts/create", "icon-pencil", "New Post"),
          actionButton("/admin/users/create", "icon-user", "New User"),
          actionButton("/admin/system/settings", "icon-cog", "Settings"),
          actionButton("/admin/cms/media", "icon-image", "Media Library"),
          actionButton("/admin/system/updates", "icon-download", "Updates")
        )
      )
    )

  private def actionButton(href: String, icon: String, label: String): Frag =
    a(cls := "btn btn-outline-secondary", attr("href") := href,
      i(cls := s"$icon me-1"), label
    )
```

Register widgets in application boot:

```scala
// In main application setup
for
  registry <- ZIO.service[WidgetRegistry]
  _ <- registry.registerWidget(new WelcomeWidget, WidgetInfo("Welcome", "Welcome message"))
  _ <- registry.registerWidget(new SystemStatusWidget, WidgetInfo("System Status", "System health", permissions = List("system.access_dashboard")))
  _ <- registry.registerWidget(new QuickActionsWidget, WidgetInfo("Quick Actions", "Common shortcuts"))
yield ()
```
  </action>
  <verify>
    mill summercms.admin.compile succeeds
    WelcomeWidget, SystemStatusWidget, QuickActionsWidget all implement DashboardWidget
    SystemStatusWidget has updates() returning Some(ZStream)
  </verify>
  <done>
    WelcomeWidget displays welcome message and getting started links
    SystemStatusWidget shows JVM version, CPU cores, memory usage with real-time updates
    QuickActionsWidget provides shortcuts to common admin tasks
    All widgets define defaultPosition for initial layout
  </done>
</task>

<task type="auto">
  <name>Task 3: SSE Endpoint for Real-Time Updates</name>
  <files>
    summercms/admin/src/dashboard/sse/WidgetEventStream.scala
  </files>
  <action>
Create the SSE endpoint for streaming widget updates:

**WidgetEventStream.scala** - Multiplexed SSE for all widgets:

```scala
package summercms.admin.dashboard.sse

import zio._
import zio.http._
import zio.stream._
import summercms.admin.dashboard._

object WidgetEventStream:
  /** Routes for SSE widget updates */
  def routes(
    widgetRegistry: WidgetRegistry,
    prefsService: DashboardPreferenceService
  ): Routes[Any, Nothing] =
    Routes(
      // Single widget stream
      Method.GET / "admin" / "dashboard" / "widget-stream" / string("alias") -> handler {
        (alias: String, req: Request) =>
          streamWidgetUpdates(alias, widgetRegistry, prefsService, req)
      }
    )

  private def streamWidgetUpdates(
    alias: String,
    registry: WidgetRegistry,
    prefs: DashboardPreferenceService,
    req: Request
  ): ZIO[Any, Nothing, Response] =
    (for
      userId <- extractUserId(req)
      configs <- prefs.getPreferences(userId, "dashboard")
      stored <- ZIO.fromOption(configs.find(_.alias == alias))
                  .orElseFail(new RuntimeException(s"Widget not found: $alias"))
      widget <- registry.resolveWidget(stored.widgetId)
                  .someOrFail(new RuntimeException(s"Unknown widget: ${stored.widgetId}"))

      config = toWidgetConfig(stored)

      // Get the update stream from the widget (if it provides one)
      stream = widget.updates(config) match
        case Some(updateStream) =>
          // Map widget updates to SSE events
          updateStream.map { update =>
            ServerSentEvent(
              data = Some(update.content.render),
              eventType = Some("content"),
              id = Some(java.util.UUID.randomUUID().toString)
            )
          }
        case None =>
          // Widget doesn't provide updates - just send heartbeats
          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,
        Header.Custom("X-Accel-Buffering", "no")  // Disable nginx buffering
      ),
      body = Body.fromCharSequenceStreamChunked(
        stream.map(sse => encodeSSE(sse))
      )
    )).catchAll { e =>
      ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.NotFound))
    }

  /** Encode SSE event to string format */
  private def encodeSSE(event: ServerSentEvent): String =
    val sb = new StringBuilder
    event.id.foreach(id => sb.append(s"id: $id\n"))
    event.eventType.foreach(t => sb.append(s"event: $t\n"))
    event.data.foreach { data =>
      // SSE data must have each line prefixed with "data: "
      data.split("\n").foreach { line =>
        sb.append(s"data: $line\n")
      }
    }
    sb.append("\n")
    sb.toString

  private def extractUserId(req: Request): Task[Long] =
    // TODO: Extract from session/JWT
    ZIO.succeed(1L)

  private def toWidgetConfig(stored: StoredWidgetConfig): WidgetConfig =
    WidgetConfig(
      alias = stored.alias,
      properties = stored.configuration.view.mapValues(jsonToAny).toMap,
      position = WidgetPosition(
        x = stored.configuration.get("x").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(0),
        y = stored.configuration.get("y").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(0),
        w = stored.configuration.get("w").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(6),
        h = stored.configuration.get("h").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(2)
      )
    )

  private def jsonToAny(json: io.circe.Json): Any =
    json.fold(
      jsonNull = null,
      jsonBoolean = identity,
      jsonNumber = n => n.toInt.getOrElse(n.toDouble),
      jsonString = identity,
      jsonArray = _.map(jsonToAny).toList,
      jsonObject = _.toMap.view.mapValues(jsonToAny).toMap
    )

/** SSE event representation */
case class ServerSentEvent(
  data: Option[String] = None,
  eventType: Option[String] = None,
  id: Option[String] = None,
  retry: Option[Int] = None
)
```

The SSE endpoint:
1. Looks up the widget by alias from user's preferences
2. Resolves the widget class from registry
3. Calls widget.updates() to get the update stream
4. Maps updates to SSE events and streams to client
5. HTMX SSE extension on frontend receives events and swaps content

Frontend integration (in Dashboard.scala) already has:
- `hx-ext="sse"` on widget container
- `sse-connect="/admin/dashboard/widget-stream/{alias}"` for connection
- `sse-swap="content"` to swap on "content" events
  </action>
  <verify>
    mill summercms.admin.compile succeeds
    WidgetEventStream.routes compiles
    SSE encoding produces valid event-stream format
  </verify>
  <done>
    SSE endpoint streams widget updates to connected clients
    Widgets without updates() receive heartbeat events to keep connection alive
    ServerSentEvent properly encoded with id, event, data fields
    X-Accel-Buffering header disables nginx buffering for real-time
  </done>
</task>

</tasks>

<verification>
1. `mill summercms.admin.compile` succeeds
2. Start server and navigate to /admin - dashboard renders with Gridstack
3. Drag/resize widgets - layout saves after 500ms debounce
4. SystemStatusWidget updates every 30 seconds via SSE
5. Add Widget popup shows available widgets
6. Remove widget removes from layout
7. Reset restores default layout
</verification>

<success_criteria>
- Dashboard page renders with Gridstack.js grid
- Widgets display with drag-drop and resize capabilities
- Layout changes saved to database automatically
- SSE endpoint streams updates to real-time widgets
- WelcomeWidget, SystemStatusWidget, QuickActionsWidget functional
- Add/Remove/Reset widget operations work
</success_criteria>

<output>
After completion, create `.planning/phases/08-admin-dashboard/08-03-SUMMARY.md`
</output>
