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
This commit is contained in:
437
.planning/phases/08-admin-dashboard/08-01-PLAN.md
Normal file
437
.planning/phases/08-admin-dashboard/08-01-PLAN.md
Normal file
@@ -0,0 +1,437 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user