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

438 lines
14 KiB
Markdown

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