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

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

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:

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

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:

  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:

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:

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.