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.
18 KiB
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;
widgetEnvelopewrapping 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
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
panelwidget type exists and is active; - six
dashboard_panel_typesexist.
Seed panel types:
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:
data PersonalDashboardsController
= PersonalDashboardAction
| EditPersonalDashboardAction
| UpdatePersonalDashboardAction
| AddDashboardPanelAction
| UpdateDashboardPanelAction { dashboardPanelId :: !(Id DashboardPanel) }
| RemoveDashboardPanelAction { dashboardPanelId :: !(Id DashboardPanel) }
deriving (Eq, Show, Data)
Register in:
Web/Routes.hswithinstance AutoRoute PersonalDashboardsControllerWeb/FrontController.hsimports and controller list- sidebar navigation as
Dashboard
4.2 Config ADTs
Store panel config in JSONB. Decode into explicit Haskell types before querying:
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:
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:
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
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
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:
- fetch current user's default dashboard;
- fetch selected active
DashboardPanelType; - find/create the framework hub;
- create linked
Widget; - create initial
WidgetVersion; - create
DashboardPanelwith default config and next layout slot; - redirect to edit.
5.5 Remove Panel
RemoveDashboardPanelAction { dashboardPanelId } should:
- verify the panel belongs to current user's dashboard;
- set
dashboard_panels.removed_at; - set linked widget
is_archived = TRUEandstatus = 'deprecated'; - keep interaction events and annotations intact;
- redirect to edit.
6. Default Dashboard Seeding
Recommended helper:
ensureDefaultDashboard :: (?modelContext :: ModelContext) => User -> IO PersonalDashboard
Behavior:
- Query default dashboard for user.
- If found, return it.
- If absent, create
personal_dashboardsrow with nameMy Dashboard. - Fetch active first-slice
DashboardPanelTyperows. - Create one linked widget and one panel row for each seed panel.
- 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_atorcreated_atfallback; - 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_snapshotsper hub; - active
bottleneck_recordscount 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_spanandrow_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.cssif 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:
PersonalDashboardActionEditPersonalDashboardActionUpdatePersonalDashboardActionAddDashboardPanelActionUpdateDashboardPanelActionRemoveDashboardPanelAction
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:
login user
redirectTo PersonalDashboardAction
Do not alter:
- public
LandingAction; - docs/tutorial/extension guide routes;
- existing
HubsActionroute.
10. Governance Lifecycle
Panel Add
- create
DashboardPanel; - create linked
Widget; - create initial
WidgetVersionsnapshot with panel type and config; - render through
widgetEnvelope.
Panel Update
- update
DashboardPanel.configand layout fields; - update panel widget name/view context only if needed;
- create a new
WidgetVersionsnapshot 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
UnsupportedPanelwith 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:
ensureDefaultDashboardis 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:
- Log in as the seeded admin user.
- Confirm redirect lands on personal dashboard.
- Confirm all six seeded panels render.
- Click source links from at least three panels.
- Open Annotate for one panel and confirm existing annotation flow loads.
- Edit layout, save, sign out/in, and confirm layout persists.
- Add a panel and remove a panel.
- 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.sqlApplication/Helper/PersonalDashboard.hsWeb/Controller/PersonalDashboards.hsWeb/View/PersonalDashboards/Show.hsWeb/View/PersonalDashboards/Edit.hsWeb/Types.hsWeb/Routes.hsWeb/FrontController.hsstatic/app.cssonly 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:
- Should personal dashboards later support team/shared dashboards?
- Should watched hubs become a first-class table after users start editing dashboards?
- Should per-panel refresh be extracted into fragment routes?
- Should dashboard panel widgets eventually be owned by source hubs instead of the framework hub?
- Should dashboard templates become part of the marketplace?
15. Handoff to WP-0021
WP-0021 should implement this design in small slices:
- schema and seeds;
- controller/route skeleton and default seeding;
- first three panel view models/renderers;
- dashboard show view;
- remaining panel view models/renderers;
- edit flow;
- governance lifecycle;
- login redirect and navigation;
- tests and smoke.