Files
inter-hub/docs/fdd/personal-dashboard-fdd.md
tegwick d6b655a5cf docs: complete personal dashboard framework and implementation plan
Finish IHUB-WP-0020 design work (status finished, all design tasks done)
and add IHUB-WP-0021 with the 12-task implementation workplan plus
research, PRs, and FDD deliverables produced during the 2026-06-16 review.
2026-06-21 16:11:37 +02:00

610 lines
18 KiB
Markdown

# Personal Dashboard Framework FDD
**Workplan:** IHUB-WP-0020
**Date:** 2026-06-16
**Status:** Functional design for follow-on implementation workplan
**Inputs:** `docs/research/personal-dashboard-current-state.md`,
`docs/prs/personal-dashboard-prs.md`
## 1. Summary
The personal dashboard is an authenticated, per-user landing surface composed of
server-rendered, governed panels. It reuses existing inter-hub data and links to
existing source dashboards. It does not replace hub dashboards, governance
dashboards, API dashboard, marketplace, or learning dashboard.
First implementation should ship:
- one default dashboard per user, with schema ready for multiple dashboards;
- six first-slice panel types;
- persisted panel layout/config;
- stable widget identity for each saved panel;
- `widgetEnvelope` wrapping for every rendered panel;
- simple server-rendered edit forms;
- post-login redirect to the personal dashboard.
## 2. Design Decisions
| Topic | Decision |
|---|---|
| Table prefix | Use `personal_dashboards`, `dashboard_panel_types`, and `dashboard_panels` |
| Panel type key field | Use `panel_key`, not `key`, to avoid ambiguous SQL/Haskell naming |
| Dashboard multiplicity | Schema supports multiple dashboards; first UI exposes only the default dashboard |
| Default dashboard | Created idempotently on first dashboard visit |
| Role defaults | No `users.role` column in first slice |
| Watched hubs | Represented in panel config for first slice, no separate watched-hub table |
| Panel widget ownership | Linked panel widgets are owned by the framework hub |
| Panel widget type | Use existing framework-level `panel` widget type |
| Panel removal | Soft-remove panel row with `removed_at`; archive linked widget |
| Rendering model | Controller/helper builds typed panel view models; views render pure HSX |
| Refresh model | Wrap the personal dashboard show action in `autoRefresh` initially |
| Client runtime | No JS framework and no client-side data fetch loop |
## 3. Schema
### 3.1 Migration Tables
```sql
CREATE TABLE personal_dashboards (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
user_id UUID NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
CREATE UNIQUE INDEX personal_dashboards_one_default_idx
ON personal_dashboards (user_id)
WHERE is_default = TRUE;
CREATE INDEX personal_dashboards_user_idx
ON personal_dashboards (user_id);
CREATE TABLE dashboard_panel_types (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
panel_key TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
description TEXT,
default_config JSONB NOT NULL DEFAULT '{}',
default_col_span INTEGER NOT NULL DEFAULT 4,
default_row_span INTEGER NOT NULL DEFAULT 1,
live_update BOOLEAN NOT NULL DEFAULT FALSE,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
CONSTRAINT dashboard_panel_types_span_check CHECK (
default_col_span BETWEEN 1 AND 12
AND default_row_span BETWEEN 1 AND 4
)
);
CREATE INDEX dashboard_panel_types_status_idx
ON dashboard_panel_types (status);
CREATE TABLE dashboard_panels (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
dashboard_id UUID NOT NULL REFERENCES personal_dashboards(id) ON DELETE CASCADE,
panel_type_id UUID NOT NULL REFERENCES dashboard_panel_types(id),
widget_id UUID NOT NULL REFERENCES widgets(id),
title TEXT,
config JSONB NOT NULL DEFAULT '{}',
col INTEGER NOT NULL DEFAULT 0,
row INTEGER NOT NULL DEFAULT 0,
col_span INTEGER NOT NULL DEFAULT 4,
row_span INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
removed_at TIMESTAMP WITH TIME ZONE,
CONSTRAINT dashboard_panels_layout_check CHECK (
col BETWEEN 0 AND 11
AND row >= 0
AND col_span BETWEEN 1 AND 12
AND row_span BETWEEN 1 AND 4
AND col + col_span <= 12
)
);
CREATE INDEX dashboard_panels_dashboard_idx
ON dashboard_panels (dashboard_id, removed_at, row, col, sort_order);
CREATE UNIQUE INDEX dashboard_panels_widget_idx
ON dashboard_panels (widget_id);
```
### 3.2 Seed Data
The implementation migration or seed helper must ensure:
- a framework hub exists with `hub_kind = 'framework'`;
- the framework-level `panel` widget type exists and is active;
- six `dashboard_panel_types` exist.
Seed panel types:
```sql
INSERT INTO dashboard_panel_types
(panel_key, label, description, default_config, default_col_span,
default_row_span, live_update)
VALUES
('watched-hubs', 'Watched Hubs',
'Hub list with latest health hints',
'{"limit":12,"displayMode":"compact"}', 6, 1, FALSE),
('recent-interactions', 'Recent Activity',
'Latest interaction events with widget and hub context',
'{"timeRange":"last24h","limit":25,"displayMode":"compact"}', 6, 1, TRUE),
('triage-queue', 'Triage Queue',
'Open requirement candidates waiting for triage',
'{"status":"open","limit":10,"sort":"oldest"}', 6, 1, TRUE),
('recent-decisions', 'Recent Decisions',
'Recent governance decisions across visible hubs',
'{"timeRange":"last30d","limit":10,"sort":"newest"}', 6, 1, FALSE),
('hub-health', 'Hub Health',
'Latest health snapshots and active bottleneck counts',
'{"limit":12,"displayMode":"compact"}', 6, 1, TRUE),
('learning-digest', 'Learning Digest',
'Recent learning insights and institutional knowledge highlights',
'{"insightLimit":5,"knowledgeLimit":5}', 6, 1, TRUE)
ON CONFLICT (panel_key) DO NOTHING;
```
The exact framework hub seed should use existing hub invariants and avoid
creating a second framework hub. Recommended slug: `inter-hub`.
## 4. Haskell Types
### 4.1 Controller Type
Add to `Web.Types`:
```haskell
data PersonalDashboardsController
= PersonalDashboardAction
| EditPersonalDashboardAction
| UpdatePersonalDashboardAction
| AddDashboardPanelAction
| UpdateDashboardPanelAction { dashboardPanelId :: !(Id DashboardPanel) }
| RemoveDashboardPanelAction { dashboardPanelId :: !(Id DashboardPanel) }
deriving (Eq, Show, Data)
```
Register in:
- `Web/Routes.hs` with `instance AutoRoute PersonalDashboardsController`
- `Web/FrontController.hs` imports and controller list
- sidebar navigation as `Dashboard`
### 4.2 Config ADTs
Store panel config in JSONB. Decode into explicit Haskell types before querying:
```haskell
data TimeRange
= Last24Hours
| Last7Days
| Last30Days
| AllTimeBounded
deriving (Eq, Show)
data DisplayMode
= Compact
| Detailed
deriving (Eq, Show)
data SortMode
= Newest
| Oldest
| HighestRisk
deriving (Eq, Show)
data DashboardPanelConfig
= WatchedHubsConfig
{ hubIds :: !(Maybe [Id Hub])
, limit :: !Int
, displayMode :: !DisplayMode
}
| RecentInteractionsConfig
{ hubIds :: !(Maybe [Id Hub])
, timeRange :: !TimeRange
, limit :: !Int
, displayMode :: !DisplayMode
}
| TriageQueueConfig
{ hubIds :: !(Maybe [Id Hub])
, status :: !Text
, limit :: !Int
, sortMode :: !SortMode
}
| RecentDecisionsConfig
{ hubIds :: !(Maybe [Id Hub])
, timeRange :: !TimeRange
, limit :: !Int
, sortMode :: !SortMode
}
| HubHealthConfig
{ hubIds :: !(Maybe [Id Hub])
, limit :: !Int
, displayMode :: !DisplayMode
}
| LearningDigestConfig
{ hubIds :: !(Maybe [Id Hub])
, insightLimit :: !Int
, knowledgeLimit :: !Int
}
```
Implementation can place these in `Application/Helper/PersonalDashboard.hs`.
Config decoding should return warnings instead of crashing:
```haskell
data PanelConfigResult
= PanelConfigOk DashboardPanelConfig
| PanelConfigWithWarnings DashboardPanelConfig [Text]
```
Clamp all limits server-side. Recommended default min/max:
- list panel limit: 1 to 50;
- hub list limit: 1 to 50;
- learning insight/knowledge limits: 1 to 20;
- column span: 1 to 12;
- row span: 1 to 4.
### 4.3 View Model ADT
Do not query from HSX views. Build typed view models in the controller/helper:
```haskell
data PersonalDashboardViewModel = PersonalDashboardViewModel
{ dashboard :: !PersonalDashboard
, panels :: ![DashboardPanelViewModel]
, panelTypes :: ![DashboardPanelType]
}
data DashboardPanelViewModel
= WatchedHubsPanel DashboardPanel Widget [WatchedHubRow] [Text]
| RecentInteractionsPanel DashboardPanel Widget [RecentInteractionRow] [Text]
| TriageQueuePanel DashboardPanel Widget [TriageQueueRow] [Text]
| RecentDecisionsPanel DashboardPanel Widget [RecentDecisionRow] [Text]
| HubHealthPanel DashboardPanel Widget [HubHealthRow] [Text]
| LearningDigestPanel DashboardPanel Widget [LearningDigestRow] [Text]
| UnsupportedPanel DashboardPanel Widget Text [Text]
```
Each row type should carry exactly the fields the view needs, plus source route
ids for link-outs.
## 5. Controller Flow
### 5.1 Show
```text
GET /PersonalDashboard
-> ensureIsUser
-> ensureDefaultDashboard currentUser
-> fetch active dashboard panels ordered by row, col, sort_order
-> build DashboardPanelViewModel for each panel
-> render ShowView
```
The first implementation may wrap `PersonalDashboardAction` in `autoRefresh do`.
### 5.2 Edit
```text
GET /PersonalDashboard/Edit
-> ensureIsUser
-> ensureDefaultDashboard currentUser
-> fetch active panels and active panel types
-> render EditView
```
Edit view should show:
- panel title;
- panel type label;
- row, col, col span, row span;
- limit/time range/hub filter where supported;
- remove button;
- add panel form.
### 5.3 Update Layout/Config
`UpdatePersonalDashboardAction` should accept a simple form payload for all
active panels. It should:
- authorize that the dashboard belongs to current user;
- validate layout bounds;
- validate per-panel config;
- update dashboard/panel `updated_at`;
- redirect back to edit or show with success/error messages.
### 5.4 Add Panel
`AddDashboardPanelAction` should:
1. fetch current user's default dashboard;
2. fetch selected active `DashboardPanelType`;
3. find/create the framework hub;
4. create linked `Widget`;
5. create initial `WidgetVersion`;
6. create `DashboardPanel` with default config and next layout slot;
7. redirect to edit.
### 5.5 Remove Panel
`RemoveDashboardPanelAction { dashboardPanelId }` should:
1. verify the panel belongs to current user's dashboard;
2. set `dashboard_panels.removed_at`;
3. set linked widget `is_archived = TRUE` and `status = 'deprecated'`;
4. keep interaction events and annotations intact;
5. redirect to edit.
## 6. Default Dashboard Seeding
Recommended helper:
```haskell
ensureDefaultDashboard :: (?modelContext :: ModelContext) => User -> IO PersonalDashboard
```
Behavior:
1. Query default dashboard for user.
2. If found, return it.
3. If absent, create `personal_dashboards` row with name `My Dashboard`.
4. Fetch active first-slice `DashboardPanelType` rows.
5. Create one linked widget and one panel row for each seed panel.
6. Return the dashboard.
Default layout:
| Panel | col | row | col_span | row_span |
|---|---:|---:|---:|---:|
| watched-hubs | 0 | 0 | 6 | 1 |
| recent-interactions | 6 | 0 | 6 | 1 |
| triage-queue | 0 | 1 | 6 | 1 |
| recent-decisions | 6 | 1 | 6 | 1 |
| hub-health | 0 | 2 | 6 | 1 |
| learning-digest | 6 | 2 | 6 | 1 |
If a user has active stewardship roles matched by email or name, panel config
may include those hub ids. If no match exists, config should stay neutral.
## 7. Panel Renderer Query Shapes
All panel queries must be bounded and must not expose secrets.
### Watched Hubs
Purpose: show hub names, domains/kinds, latest health score if available, and a
link to `ShowHubAction`.
Query shape:
- fetch hubs matching optional hub filter, order by name, limit N;
- fetch latest health snapshots for those hub ids using `DISTINCT ON (hub_id)`
or equivalent bounded query.
### Recent Activity
Purpose: show recent interaction events with widget and hub context.
Query shape:
- filter by optional hub ids through widget join;
- filter by time range;
- order by `interaction_events.occurred_at DESC`;
- limit N;
- fetch widget/hub names for display.
### Triage Queue
Purpose: show open requirement candidates that need attention.
Query shape:
- filter `requirement_candidates.status = 'open'`;
- optionally filter by source widget hub;
- order oldest first by default;
- limit N;
- fetch source widget and hub names.
### Recent Decisions
Purpose: show governance decisions that changed recently.
Query shape:
- filter by time range on `decided_at` or `created_at` fallback;
- optionally filter by hub through requirement candidate source widget lineage;
- order newest first;
- limit N.
### Hub Health
Purpose: show latest health score and active bottleneck count.
Query shape:
- latest `hub_health_snapshots` per hub;
- active `bottleneck_records` count per hub;
- limit N hubs by health score ascending or hub name depending config.
When decoding aggregate counts as `Int`, cast `COUNT(*) AS integer` or decode
as `Int64`.
### Learning Digest
Purpose: show recent `learning_insights` and
`institutional_knowledge_entries`.
Query shape:
- optional hub filter;
- latest insights ordered by `computed_at DESC`, limit N;
- latest knowledge entries ordered by `created_at DESC`, limit N;
- link to source knowledge entries when available.
## 8. Layout
Desktop layout:
- 12-column CSS grid.
- panel spans come from `dashboard_panels.col_span` and `row_span`.
- layout ordering comes from row, col, sort order.
- gap should match existing dashboard spacing.
Mobile/narrow layout:
- collapse to a single column.
- ignore column positions visually.
- preserve row/sort ordering.
Implementation approach:
- add a small CSS helper in `static/app.css` if inline styles cannot express the
responsive collapse cleanly;
- keep panel headings at compact dashboard scale;
- avoid nested cards;
- keep source link and annotate control visible but quiet.
## 9. Routing and Navigation
Add:
- `PersonalDashboardAction`
- `EditPersonalDashboardAction`
- `UpdatePersonalDashboardAction`
- `AddDashboardPanelAction`
- `UpdateDashboardPanelAction`
- `RemoveDashboardPanelAction`
Expected user-facing routes with AutoRoute naming are acceptable. If a cleaner
path is desired, add explicit `HasPath`/`CanRoute` later. First implementation
can use AutoRoute for speed and consistency.
Update login:
```haskell
login user
redirectTo PersonalDashboardAction
```
Do not alter:
- public `LandingAction`;
- docs/tutorial/extension guide routes;
- existing `HubsAction` route.
## 10. Governance Lifecycle
### Panel Add
- create `DashboardPanel`;
- create linked `Widget`;
- create initial `WidgetVersion` snapshot with panel type and config;
- render through `widgetEnvelope`.
### Panel Update
- update `DashboardPanel.config` and layout fields;
- update panel widget name/view context only if needed;
- create a new `WidgetVersion` snapshot when config changes materially.
### Panel Remove
- set `dashboard_panels.removed_at`;
- set widget `is_archived = TRUE`;
- set widget `status = 'deprecated'`;
- preserve events and annotations.
### Annotation
The existing `WidgetAnnotationsAction` should work because panels have stable
widget ids.
### Event Capture
Existing client-side capture can identify panels via `data-widget-id`. If panel
forms submit through normal controller actions, use existing event types where
possible (`viewed`, `clicked`, `submitted`, `commented`).
## 11. Error Handling
- Missing dashboard: create default.
- Missing panel type: render `UnsupportedPanel` with warning.
- Invalid config: use defaults and render warning.
- Missing linked widget: repair by creating a replacement widget if possible,
otherwise render unsupported warning.
- Missing framework hub: create the framework hub if absent, honoring unique
framework hub constraint.
- Empty panel data: render a quiet empty state.
## 12. Tests and Smoke Checks
Focused automated checks:
- `ensureDefaultDashboard` is idempotent.
- seeded dashboard contains six active panels.
- each seeded panel has a linked widget.
- config decoder clamps limits and rejects unknown values safely.
- unauthorized user cannot edit another user's dashboard.
- remove action soft-removes panel and archives widget.
Manual smoke:
1. Log in as the seeded admin user.
2. Confirm redirect lands on personal dashboard.
3. Confirm all six seeded panels render.
4. Click source links from at least three panels.
5. Open Annotate for one panel and confirm existing annotation flow loads.
6. Edit layout, save, sign out/in, and confirm layout persists.
7. Add a panel and remove a panel.
8. Confirm `HubsAction`, hub show, Ops Review, Learning, API Dashboard, and
Marketplace still load.
## 13. Implementation File Map
Expected files for WP-0021:
- `Application/Migration/<timestamp>-personal-dashboard-framework.sql`
- `Application/Helper/PersonalDashboard.hs`
- `Web/Controller/PersonalDashboards.hs`
- `Web/View/PersonalDashboards/Show.hs`
- `Web/View/PersonalDashboards/Edit.hs`
- `Web/Types.hs`
- `Web/Routes.hs`
- `Web/FrontController.hs`
- `static/app.css` only if needed for responsive grid helpers
- focused tests under `Test/` if the current test harness supports controller or
helper tests
## 14. Open Questions
These do not block WP-0021, but should be revisited after the first
implementation:
1. Should personal dashboards later support team/shared dashboards?
2. Should watched hubs become a first-class table after users start editing
dashboards?
3. Should per-panel refresh be extracted into fragment routes?
4. Should dashboard panel widgets eventually be owned by source hubs instead of
the framework hub?
5. Should dashboard templates become part of the marketplace?
## 15. Handoff to WP-0021
WP-0021 should implement this design in small slices:
1. schema and seeds;
2. controller/route skeleton and default seeding;
3. first three panel view models/renderers;
4. dashboard show view;
5. remaining panel view models/renderers;
6. edit flow;
7. governance lifecycle;
8. login redirect and navigation;
9. tests and smoke.