Files
Jakub Zych 5b0928f292 docs(08): create phase plan
Phase 08: Admin Dashboard
- 4 plan(s) in 2 wave(s)
- 2 parallel (08-01, 08-02), 2 sequential (08-03, 08-04)
- Ready for execution
2026-02-05 15:16:30 +01:00

14 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
phase plan type wave depends_on files_modified autonomous must_haves
08-admin-dashboard 01 execute 1
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
true
truths artifacts key_links
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
path provides contains
summercms/admin/src/dashboard/DashboardWidget.scala Base trait and types for dashboard widgets trait DashboardWidget
path provides exports
summercms/admin/src/dashboard/WidgetRegistry.scala Widget registration and lookup service
WidgetRegistry
path provides exports
summercms/admin/src/dashboard/DashboardPreferenceService.scala Per-user layout persistence
DashboardPreferenceService
path provides contains
summercms/db/src/main/resources/db/migration/V005__dashboard_preferences.sql Dashboard preferences table CREATE TABLE dashboard_preferences
from to via pattern
WidgetRegistry DashboardWidget registerWidget method accepts widget class def registerWidget.*DashboardWidget
from to via pattern
DashboardPreferenceService Quill database queries run(query
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

<execution_context> @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md </execution_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

Task 1: Dashboard Widget Base Types summercms/admin/src/dashboard/DashboardWidget.scala summercms/admin/src/dashboard/WidgetRegistry.scala Create the dashboard widget system foundation:

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

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:

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. mill summercms.admin.compile succeeds Types DashboardWidget, WidgetRegistry, PropertyDef exist and compile DashboardWidget trait defines render/updates/defineProperties contract WidgetRegistry provides register/list/resolve operations with ZIO Ref All supporting case classes (PropertyDef, WidgetConfig, etc.) defined

Task 2: Dashboard Preferences Service summercms/admin/src/dashboard/DashboardPreference.scala summercms/admin/src/dashboard/DashboardPreferenceService.scala summercms/db/src/main/resources/db/migration/V005__dashboard_preferences.sql Create the preferences persistence layer for per-user dashboard layouts:

V005__dashboard_preferences.sql - Migration for preferences table:

-- 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:

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:

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. mill summercms.db.flywayMigrate runs migration V005 successfully mill summercms.admin.compile succeeds DashboardPreferenceService compiles with Quill queries 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

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

<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>
After completion, create `.planning/phases/08-admin-dashboard/08-01-SUMMARY.md`