---
phase: 08-admin-dashboard
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - summercms/admin/src/dashboard/DashboardWidget.scala
  - summercms/admin/src/dashboard/WidgetRegistry.scala
  - summercms/admin/src/dashboard/DashboardPreference.scala
  - summercms/admin/src/dashboard/DashboardPreferenceService.scala
  - summercms/db/src/main/resources/db/migration/V005__dashboard_preferences.sql
autonomous: true

must_haves:
  truths:
    - "Widget classes can be registered with metadata during plugin boot"
    - "Dashboard layouts are stored per-user in database"
    - "Default widget layout loads when user has no preferences"
  artifacts:
    - path: "summercms/admin/src/dashboard/DashboardWidget.scala"
      provides: "Base trait and types for dashboard widgets"
      contains: "trait DashboardWidget"
    - path: "summercms/admin/src/dashboard/WidgetRegistry.scala"
      provides: "Widget registration and lookup service"
      exports: ["WidgetRegistry"]
    - path: "summercms/admin/src/dashboard/DashboardPreferenceService.scala"
      provides: "Per-user layout persistence"
      exports: ["DashboardPreferenceService"]
    - path: "summercms/db/src/main/resources/db/migration/V005__dashboard_preferences.sql"
      provides: "Dashboard preferences table"
      contains: "CREATE TABLE dashboard_preferences"
  key_links:
    - from: "WidgetRegistry"
      to: "DashboardWidget"
      via: "registerWidget method accepts widget class"
      pattern: "def registerWidget.*DashboardWidget"
    - from: "DashboardPreferenceService"
      to: "Quill"
      via: "database queries"
      pattern: "run\\(query"
---

<objective>
Create the foundation for dashboard widgets: the base trait defining widget contracts, a registry service for collecting widgets from plugins, and a per-user preferences service for storing dashboard layouts.

Purpose: Enable plugins to register dashboard widgets and persist user customizations
Output: DashboardWidget trait, WidgetRegistry, DashboardPreferenceService, migration
</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

# Phase 1 established Quill patterns
@summercms/db/src/main/scala/summercms/db/QuillContext.scala
</context>

<tasks>

<task type="auto">
  <name>Task 1: Dashboard Widget Base Types</name>
  <files>
    summercms/admin/src/dashboard/DashboardWidget.scala
    summercms/admin/src/dashboard/WidgetRegistry.scala
  </files>
  <action>
Create the dashboard widget system foundation:

**DashboardWidget.scala** - Define the base trait and supporting types:

```scala
package summercms.admin.dashboard

import scalatags.Text.all._
import zio._
import zio.stream._

/** Property type for widget configuration */
enum PropertyType:
  case String, Number, Checkbox, Dropdown, DateRange

/** Definition of a configurable widget property */
case class PropertyDef(
  title: String,
  `type`: PropertyType,
  description: Option[String] = None,
  default: Option[String] = None,
  options: Map[String, String] = Map.empty,
  required: Boolean = false
)

/** Position and size of widget in Gridstack grid */
case class WidgetPosition(
  x: Int,
  y: Int,
  w: Int,
  h: Int
)

/** Runtime configuration for a widget instance */
case class WidgetConfig(
  alias: String,
  properties: Map[String, Any],
  position: WidgetPosition
)

/** Update message for real-time widget refresh */
case class WidgetUpdate(
  widgetAlias: String,
  content: Frag
)

/** Context passed to widget render method */
case class DashboardContext(
  userId: Long,
  userName: String,
  permissions: Set[String]
)

/** Base trait for all dashboard widgets */
trait DashboardWidget:
  /** Unique widget identifier (class name or custom) */
  def widgetId: String

  /** Default alias when adding widget */
  def defaultAlias: String = widgetId

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

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

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

  /** Default grid position and size */
  def defaultPosition: WidgetPosition = WidgetPosition(0, 0, 6, 2)

  /** Render widget content */
  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
```

**WidgetRegistry.scala** - Service for collecting and resolving widgets:

```scala
package summercms.admin.dashboard

import zio._

/** Metadata about a registered widget */
case class WidgetInfo(
  name: String,
  description: String,
  context: List[String] = List("dashboard"),
  permissions: List[String] = Nil
)

/** Registry for dashboard widgets */
trait WidgetRegistry:
  def registerWidget(widget: DashboardWidget, info: WidgetInfo): UIO[Unit]
  def listWidgets: UIO[Map[String, (WidgetInfo, DashboardWidget)]]
  def listWidgetsForContext(context: String): UIO[Map[String, (WidgetInfo, DashboardWidget)]]
  def resolveWidget(widgetId: String): UIO[Option[DashboardWidget]]

object WidgetRegistry:
  val live: ZLayer[Any, Nothing, WidgetRegistry] =
    ZLayer.fromZIO {
      Ref.make(Map.empty[String, (WidgetInfo, DashboardWidget)]).map { widgetsRef =>
        new WidgetRegistry:
          def registerWidget(widget: DashboardWidget, info: WidgetInfo): UIO[Unit] =
            widgetsRef.update(_ + (widget.widgetId -> (info, widget)))

          def listWidgets: UIO[Map[String, (WidgetInfo, DashboardWidget)]] =
            widgetsRef.get

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

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

  def registerWidget(widget: DashboardWidget, info: WidgetInfo): ZIO[WidgetRegistry, Nothing, Unit] =
    ZIO.serviceWithZIO[WidgetRegistry](_.registerWidget(widget, info))

  def listWidgets: ZIO[WidgetRegistry, Nothing, Map[String, (WidgetInfo, DashboardWidget)]] =
    ZIO.serviceWithZIO[WidgetRegistry](_.listWidgets)

  def resolveWidget(widgetId: String): ZIO[WidgetRegistry, Nothing, Option[DashboardWidget]] =
    ZIO.serviceWithZIO[WidgetRegistry](_.resolveWidget(widgetId))
```

Use ZIO Ref for thread-safe mutable state. Widget IDs are the key for lookup.
  </action>
  <verify>
    mill summercms.admin.compile succeeds
    Types DashboardWidget, WidgetRegistry, PropertyDef exist and compile
  </verify>
  <done>
    DashboardWidget trait defines render/updates/defineProperties contract
    WidgetRegistry provides register/list/resolve operations with ZIO Ref
    All supporting case classes (PropertyDef, WidgetConfig, etc.) defined
  </done>
</task>

<task type="auto">
  <name>Task 2: Dashboard Preferences Service</name>
  <files>
    summercms/admin/src/dashboard/DashboardPreference.scala
    summercms/admin/src/dashboard/DashboardPreferenceService.scala
    summercms/db/src/main/resources/db/migration/V005__dashboard_preferences.sql
  </files>
  <action>
Create the preferences persistence layer for per-user dashboard layouts:

**V005__dashboard_preferences.sql** - Migration for preferences table:

```sql
-- Dashboard preferences per user
CREATE TABLE dashboard_preferences (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,
    context VARCHAR(50) NOT NULL DEFAULT 'dashboard',
    widgets JSONB NOT NULL DEFAULT '[]'::jsonb,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),

    CONSTRAINT uq_dashboard_prefs_user_context UNIQUE (user_id, context)
);

CREATE INDEX idx_dashboard_prefs_user ON dashboard_preferences(user_id);

-- Trigger to update updated_at
CREATE TRIGGER update_dashboard_prefs_updated_at
    BEFORE UPDATE ON dashboard_preferences
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();
```

**DashboardPreference.scala** - Model and stored config types:

```scala
package summercms.admin.dashboard

import io.circe._
import io.circe.generic.semiauto._
import java.time.OffsetDateTime

/** Stored widget configuration in preferences JSON */
case class StoredWidgetConfig(
  widgetId: String,
  alias: String,
  sortOrder: Int,
  configuration: Map[String, Json]  // Using Json for flexibility
)

object StoredWidgetConfig:
  given Encoder[StoredWidgetConfig] = deriveEncoder
  given Decoder[StoredWidgetConfig] = deriveDecoder

/** Database row for dashboard_preferences */
case class DashboardPreference(
  id: Long,
  userId: Long,
  context: String,
  widgets: Json,  // JSONB stored as circe Json
  createdAt: OffsetDateTime,
  updatedAt: OffsetDateTime
)
```

**DashboardPreferenceService.scala** - Service for CRUD operations:

```scala
package summercms.admin.dashboard

import io.circe._
import io.circe.syntax._
import io.circe.parser._
import zio._
import summercms.db.QuillContext

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:
  val live: ZLayer[QuillContext, Nothing, DashboardPreferenceService] =
    ZLayer.fromZIO {
      ZIO.service[QuillContext].map { quill =>
        import quill.ctx._

        new DashboardPreferenceService:
          def getPreferences(userId: Long, context: String): Task[List[StoredWidgetConfig]] =
            for
              prefs <- run(
                query[DashboardPreference]
                  .filter(p => p.userId == lift(userId) && p.context == lift(context))
              )
              result <- prefs.headOption match
                case Some(pref) => ZIO.fromEither(
                  pref.widgets.as[List[StoredWidgetConfig]]
                ).orElse(getDefaultLayout(context))
                case None => getDefaultLayout(context)
            yield result

          def setPreferences(userId: Long, context: String, widgets: List[StoredWidgetConfig]): Task[Unit] =
            val widgetsJson = widgets.asJson
            val now = java.time.OffsetDateTime.now()
            for
              existing <- run(
                query[DashboardPreference]
                  .filter(p => p.userId == lift(userId) && p.context == lift(context))
              )
              _ <- existing.headOption match
                case Some(pref) =>
                  run(
                    query[DashboardPreference]
                      .filter(_.id == lift(pref.id))
                      .update(
                        _.widgets -> lift(widgetsJson),
                        _.updatedAt -> lift(now)
                      )
                  ).unit
                case None =>
                  run(
                    query[DashboardPreference]
                      .insert(
                        _.userId -> lift(userId),
                        _.context -> lift(context),
                        _.widgets -> lift(widgetsJson),
                        _.createdAt -> lift(now),
                        _.updatedAt -> lift(now)
                      )
                  ).unit
            yield ()

          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]] =
            // Default layout - will be populated when core widgets registered
            ZIO.succeed(List(
              StoredWidgetConfig(
                widgetId = "welcome",
                alias = "welcome_default",
                sortOrder = 10,
                configuration = Map(
                  "x" -> Json.fromInt(0),
                  "y" -> Json.fromInt(0),
                  "w" -> Json.fromInt(7),
                  "h" -> Json.fromInt(3)
                )
              ),
              StoredWidgetConfig(
                widgetId = "system-status",
                alias = "status_default",
                sortOrder = 20,
                configuration = Map(
                  "x" -> Json.fromInt(7),
                  "y" -> Json.fromInt(0),
                  "w" -> Json.fromInt(5),
                  "h" -> Json.fromInt(3)
                )
              )
            ))
      }
    }
```

Ensure Quill can handle JSONB columns - may need to add circe-json encoder/decoder for Quill if not already present. Check existing QuillContext for custom encoders.
  </action>
  <verify>
    mill summercms.db.flywayMigrate runs migration V005 successfully
    mill summercms.admin.compile succeeds
    DashboardPreferenceService compiles with Quill queries
  </verify>
  <done>
    Migration V005 creates dashboard_preferences table with JSONB widgets column
    DashboardPreference model maps to table
    StoredWidgetConfig has circe codecs for JSON serialization
    DashboardPreferenceService provides get/set/reset operations
    Default layout returns welcome + system-status widgets
  </done>
</task>

</tasks>

<verification>
1. `mill summercms.admin.compile` succeeds
2. `mill summercms.db.flywayMigrate` applies V005 migration
3. Database has dashboard_preferences table with correct schema
4. All types in DashboardWidget.scala compile
5. WidgetRegistry.live layer constructs successfully
</verification>

<success_criteria>
- DashboardWidget trait established with render/updates/defineProperties
- WidgetRegistry collects and resolves widgets by ID
- DashboardPreferenceService persists per-user layouts to PostgreSQL
- Migration creates dashboard_preferences table with JSONB support
- Default layout defined for fresh installations
</success_criteria>

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