@exxatdesignux/ui 0.2.9 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/consumer-extras/cursor-skills/exxat-card-vs-list-rows/SKILL.md +20 -0
  2. package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +33 -0
  3. package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +45 -0
  4. package/consumer-extras/cursor-skills/exxat-drawer-vs-dialog/SKILL.md +20 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +31 -5
  6. package/consumer-extras/cursor-skills/exxat-kpi-max-four/SKILL.md +19 -0
  7. package/consumer-extras/cursor-skills/exxat-kpi-trends/SKILL.md +27 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +1 -0
  9. package/consumer-extras/patterns/collaboration-access-pattern.md +114 -0
  10. package/consumer-extras/patterns/data-views-pattern.md +12 -4
  11. package/package.json +1 -1
  12. package/src/components/ui/banner.tsx +20 -7
  13. package/src/components/ui/date-picker-field.tsx +3 -3
  14. package/src/components/ui/dropdown-menu.tsx +17 -6
  15. package/src/components/ui/input-group.tsx +1 -1
  16. package/src/components/ui/input.tsx +1 -1
  17. package/src/components/ui/select.tsx +1 -1
  18. package/src/components/ui/separator.tsx +2 -2
  19. package/src/components/ui/sidebar.tsx +31 -3
  20. package/src/components/ui/textarea.tsx +1 -1
  21. package/src/globals.css +0 -1
  22. package/src/index.ts +1 -0
  23. package/src/lib/date-filter.ts +13 -4
  24. package/src/lib/dropdown-menu-surface.ts +13 -0
  25. package/template/.claude/skills/exxat-ds-skill/SKILL.md +27 -9
  26. package/template/.cursor/rules/exxat-data-tables.mdc +1 -0
  27. package/template/AGENTS.md +82 -27
  28. package/template/app/(app)/examples/page.tsx +2 -1
  29. package/template/app/(app)/help/page.tsx +6 -0
  30. package/template/app/(app)/layout.tsx +7 -4
  31. package/template/app/(app)/question-bank/find/page.tsx +12 -0
  32. package/template/app/(app)/question-bank/layout.tsx +46 -0
  33. package/template/app/(app)/question-bank/library/page.tsx +11 -0
  34. package/template/app/(app)/question-bank/list/page.tsx +12 -0
  35. package/template/app/(app)/question-bank/page.tsx +4 -3
  36. package/template/app/globals.css +1 -2
  37. package/template/components/app-sidebar.tsx +51 -13
  38. package/template/components/ask-leo-composer.tsx +173 -45
  39. package/template/components/ask-leo-sidebar.tsx +9 -1
  40. package/template/components/chart-area-interactive.tsx +3 -13
  41. package/template/components/charts-overview.tsx +33 -6
  42. package/template/components/collaboration-access-flow.tsx +144 -0
  43. package/template/components/compliance-page-header.tsx +1 -1
  44. package/template/components/compliance-table.tsx +2 -2
  45. package/template/components/dashboard-tabs.tsx +4 -3
  46. package/template/components/data-list-table-cells.tsx +1 -1
  47. package/template/components/data-list-table.tsx +1 -1
  48. package/template/components/data-table/index.tsx +5 -5
  49. package/template/components/data-table/use-table-state.ts +18 -2
  50. package/template/components/data-view-dashboard-charts-compliance.tsx +8 -5
  51. package/template/components/data-view-dashboard-charts-team.tsx +8 -5
  52. package/template/components/data-view-dashboard-charts.tsx +62 -227
  53. package/template/components/dedicated-search-recents.tsx +96 -0
  54. package/template/components/dedicated-search-url-composer.tsx +112 -0
  55. package/template/components/getting-started.tsx +1 -1
  56. package/template/components/hub-tree-panel-view.tsx +10 -26
  57. package/template/components/invite-collaborators-drawer.tsx +453 -0
  58. package/template/components/key-metrics.tsx +54 -8
  59. package/template/components/nav-documents.tsx +1 -1
  60. package/template/components/new-placement-form.tsx +3 -3
  61. package/template/components/page-header.tsx +76 -59
  62. package/template/components/placements-board-view.tsx +3 -3
  63. package/template/components/placements-page-header.tsx +1 -1
  64. package/template/components/placements-table-columns.tsx +3 -2
  65. package/template/components/product-switcher.tsx +0 -1
  66. package/template/components/question-bank-board-view.tsx +35 -47
  67. package/template/components/question-bank-client.tsx +293 -81
  68. package/template/components/question-bank-dashboard-charts.tsx +174 -0
  69. package/template/components/question-bank-favorite-button.tsx +46 -0
  70. package/template/components/question-bank-hub-client.tsx +436 -0
  71. package/template/components/question-bank-list-view.tsx +26 -19
  72. package/template/components/question-bank-new-folder-sheet.tsx +56 -42
  73. package/template/components/question-bank-os-folder-view.tsx +3 -14
  74. package/template/components/question-bank-page-header.tsx +85 -53
  75. package/template/components/question-bank-panel-activator.tsx +3 -4
  76. package/template/components/question-bank-secondary-nav.tsx +523 -65
  77. package/template/components/question-bank-table.tsx +125 -343
  78. package/template/components/secondary-panel.tsx +130 -63
  79. package/template/components/settings-client.tsx +3 -1
  80. package/template/components/sidebar-shell.tsx +2 -0
  81. package/template/components/sites-all-client.tsx +1 -1
  82. package/template/components/sites-table.tsx +1 -1
  83. package/template/components/system-banner-slot.tsx +2 -1
  84. package/template/components/table-properties/drawer.tsx +3 -3
  85. package/template/components/table-properties/sort-card.tsx +1 -1
  86. package/template/components/team-page-header.tsx +1 -1
  87. package/template/components/team-table.tsx +8 -4
  88. package/template/components/templates/dedicated-search-landing-template.tsx +58 -0
  89. package/template/components/templates/dedicated-search-results-template.tsx +19 -0
  90. package/template/components/templates/discovery-hub-template.tsx +273 -0
  91. package/template/components/templates/list-page.tsx +11 -4
  92. package/template/components/templates/nested-secondary-panel-shell.tsx +57 -0
  93. package/template/components/templates/secondary-panel-hub-template.tsx +54 -0
  94. package/template/docs/card-vs-rows-pattern.md +36 -0
  95. package/template/docs/collaboration-access-pattern.md +114 -0
  96. package/template/docs/data-views-pattern.md +12 -4
  97. package/template/docs/drawer-vs-dialog-pattern.md +50 -0
  98. package/template/docs/kpi-strip-max-four-pattern.md +29 -0
  99. package/template/docs/kpi-trend-pattern.md +43 -0
  100. package/template/fontawesome-subset.manifest.json +2 -2
  101. package/template/hooks/use-location-hash.ts +14 -8
  102. package/template/hooks/use-secondary-panel-hub-nav.ts +98 -0
  103. package/template/lib/ask-leo-route-context.ts +24 -0
  104. package/template/lib/collaborator-access.ts +92 -0
  105. package/template/lib/command-menu-config.ts +8 -1
  106. package/template/lib/command-menu-search-data.ts +11 -8
  107. package/template/lib/data-list-display-options.ts +1 -1
  108. package/template/lib/data-view-dashboard-placements-layout.ts +215 -0
  109. package/template/lib/date-filter.ts +1 -0
  110. package/template/lib/dedicated-search-recents.ts +76 -0
  111. package/template/lib/dedicated-search-url.ts +23 -0
  112. package/template/lib/discovery-hub.ts +15 -0
  113. package/template/lib/list-status-badges.ts +1 -21
  114. package/template/lib/mock/navigation.tsx +4 -2
  115. package/template/lib/mock/placements.ts +9 -9
  116. package/template/lib/mock/question-bank-folders.ts +7 -0
  117. package/template/lib/mock/question-bank-header-collaborators.ts +45 -5
  118. package/template/lib/mock/question-bank-inspector.ts +1 -2
  119. package/template/lib/mock/question-bank-kpi.ts +38 -26
  120. package/template/lib/mock/question-bank.ts +43 -16
  121. package/template/lib/question-bank-dedicated-search.ts +19 -0
  122. package/template/lib/question-bank-hub-search.ts +90 -0
  123. package/template/lib/question-bank-nav.ts +322 -6
  124. package/template/lib/question-bank-recent-searches.ts +22 -0
  125. package/template/package.json +0 -1
@@ -0,0 +1,50 @@
1
+ # Drawer vs dialog vs route
2
+
3
+ > **Related:** `docs/data-views-pattern.md` (Page vs drawer), **`AGENTS.md` §6.4**, **`.cursor/rules/exxat-page-vs-drawer.mdc`**. **This doc** splits **drawer/sheet** vs **modal dialog** when both stay on the same route.
4
+
5
+ ## Drawer / sheet (side panel)
6
+
7
+ **Use when:**
8
+
9
+ - The **list or hub behind the panel** still matters (user compares, copies, or dismisses and continues browsing).
10
+ - The flow is **medium length** — export options, table/column properties, invite collaborators, filters that mirror the grid.
11
+ - **Width** helps — tables of options, multi-field forms that would feel cramped in a narrow dialog.
12
+
13
+ **Examples in repo:** `TablePropertiesDrawer`, `ExportDrawer`, `InviteCollaboratorsDrawer`, `Drawer` shells that slide from the edge.
14
+
15
+ **Avoid when:** The task is the **only** thing the user should focus on and the parent would distract (prefer **dialog** for a sharp confirm, or **route** for a full wizard).
16
+
17
+ ## Dialog (modal)
18
+
19
+ **Use when:**
20
+
21
+ - You need a **hard stop** — user must answer or dismiss before interacting with the page again (confirm delete, acknowledge legal, pick a single blocking choice).
22
+ - The content is **short and focused** — one decision, one form step, or a compact message with primary/secondary actions.
23
+ - **Destructive or irreversible** actions — pair with clear copy; **Esc** / Cancel returns safely.
24
+
25
+ **Examples:** `AlertDialog`, confirm-before-remove, “Save changes?” when navigating away.
26
+
27
+ **Avoid when:** Users need to **reference** the grid or copy values from the page while the panel is open — use a **drawer** or **inline** pattern instead.
28
+
29
+ ## Route (new page)
30
+
31
+ Use when the work is **primary**, **long**, **multi-step**, or deserves its **own URL** — see **`exxat-page-vs-drawer.mdc`** and **`AGENTS.md` §6.4**.
32
+
33
+ ## Quick matrix
34
+
35
+ | Need | Drawer | Dialog | Route |
36
+ | --- | --- | --- | --- |
37
+ | Keep hub visible | Yes | No (blocks) | No |
38
+ | Short confirm / alert | Rare | Yes | Overkill |
39
+ | Long form / wizard | Cramped | No | Yes |
40
+ | Properties tied to a table | Yes | Too small | Optional |
41
+
42
+ ## Accessibility
43
+
44
+ - **Dialog / drawer / sheet:** Must expose a **title** (`DialogTitle`, `SheetTitle`, `DrawerTitle`) — use `sr-only` if visually hidden.
45
+ - **Focus trap** is expected in dialogs; drawers should still **restore focus** on close to the invoking control.
46
+
47
+ ## See also
48
+
49
+ - **`.cursor/rules/exxat-drawer-vs-dialog.mdc`**, **`.cursor/skills/exxat-drawer-vs-dialog/SKILL.md`**
50
+ - **`exxat-no-toast.mdc`** — use dialog/banner/inline, not toasts, for outcomes that need acknowledgment.
@@ -0,0 +1,29 @@
1
+ # KPI strip — maximum four tiles
2
+
3
+ > **Code:** `lib/dashboard-layout-merge.ts` — **`KEY_METRICS_KPI_COUNT_MIN`**, **`KEY_METRICS_KPI_COUNT_MAX`** (4), **`clampKeyMetricsKpiCount`**. **Component:** `KeyMetrics` in `components/key-metrics.tsx`.
4
+
5
+ ## Rule
6
+
7
+ On **primary list hubs** (`ListPageTemplate` metrics slot) and on **dashboard “key metrics” cards** (Data tab chart bundles), **show at most four** `MetricItem` tiles at once.
8
+
9
+ ## Why four
10
+
11
+ - **Scanning** — More than four headline numbers compete; users miss deltas and period context.
12
+ - **Layout** — `KeyMetrics` wraps to multiple rows; four keeps one or two clean rows on common breakpoints (including **`metricsHalfWidthLayout`** on span-1 cards).
13
+ - **Persistence** — Dashboard layout already stores **`keyMetricsKpiCount`** in **`1…4`**; list-page KPI helpers should **not** return a fifth tile expecting it to display.
14
+
15
+ ## Implementation
16
+
17
+ 1. **KPI builders** (`lib/mock/*-kpi.ts`) — Return **≤ 4** items, or **`.slice(0, 4)`** after prioritizing (hero total + top three drivers). Merge extras into **`MetricInsight`** copy instead of a fifth tile when possible.
18
+ 2. **Dashboard canvas** — Never raise **`KEY_METRICS_KPI_COUNT_MAX`**; use **`clampKeyMetricsKpiCount`** when reading saved JSON.
19
+ 3. **Full-page dashboards** — If more summaries are needed, add **sections** (charts, tables, secondary cards), not a fifth KPI in the same strip.
20
+
21
+ ## MUST NOT
22
+
23
+ - Ship **five+** `MetricItem` entries in a single **`KeyMetrics`** band meant as the **primary** KPI row for a hub or the **key-metrics** dashboard card.
24
+ - Duplicate the same metric as two tiles to pad count — prefer **insight rail** or **`MetricInsight`**.
25
+
26
+ ## See also
27
+
28
+ - **`docs/kpi-trend-pattern.md`** — deltas, arrows, **`trendPolarity`**.
29
+ - **`.cursor/rules/exxat-kpi-max-four.mdc`**, **`.cursor/skills/exxat-kpi-max-four/SKILL.md`**
@@ -0,0 +1,43 @@
1
+ # KPI trend arrows and deltas (`KeyMetrics`)
2
+
3
+ > **Handbook:** [`AGENTS.md`](../AGENTS.md) (mock KPI helpers, `KeyMetrics`). **Component:** [`components/key-metrics.tsx`](../components/key-metrics.tsx). **Cursor:** [`.cursor/rules/exxat-kpi-trends.mdc`](../../.cursor/rules/exxat-kpi-trends.mdc). **Skill:** [`.cursor/skills/exxat-kpi-trends/SKILL.md`](../../.cursor/skills/exxat-kpi-trends/SKILL.md).
4
+
5
+ ## Goals
6
+
7
+ 1. **Contextual** — The **label**, **value format** (count, %, currency, days), and **comparison period** (e.g. “vs last week”) must read as one story. Do not paste a generic “+12%” without tying it to what moved.
8
+ 2. **Honest direction** — **`trend`** (`up` | `down` | `neutral`) always matches the **signed change** in the underlying metric so the **arrow** reflects reality.
9
+ 3. **Correct sentiment** — **`trendPolarity`** decides whether “up” is **good news** (tint + assistive copy), **bad news**, or **informational** (muted — direction only).
10
+
11
+ ## `MetricItem` fields
12
+
13
+ | Field | Role |
14
+ | --- | --- |
15
+ | `value` | Current bucket total or rate (formatted string or number). |
16
+ | `delta` | Human-readable delta for the selected period, e.g. `+5`, `-3`, `+12%`, `—` when no comparison. |
17
+ | `trend` | **Visual direction** of that delta: more → `up`, less → `down`, flat / N/A → `neutral`. |
18
+ | `trendPolarity` | Optional. **`higher_is_better`** (default) \| **`lower_is_better`** \| **`informational`**. |
19
+
20
+ ## Polarity cheat sheet
21
+
22
+ | `trendPolarity` | Use when | Up arrow tint | Down arrow tint |
23
+ | --- | --- | --- | --- |
24
+ | **`higher_is_better`** (default) | Revenue, pass rate, completions, enrolled count, positive CSAT | Favorable (brand / chart positive token) | Unfavorable (destructive) |
25
+ | **`lower_is_better`** | Error rate, overdue tasks, **low PBI / item quality flags**, time-on-task when minimizing, spend when cutting cost | Unfavorable | Favorable |
26
+ | **`informational`** | Library size, mix %, neutral volume | Muted | Muted |
27
+
28
+ **Psychometrics example:** Point-biserial (PBI) **dropping** usually helps discrimination — often good (`lower_is_better` on a *“low quality” count* is clearer: **count of items below a review threshold** rising → `trend: "up"` + `trendPolarity: "lower_is_better"` → arrow up with **unfavorable** tint).
29
+
30
+ ## Accessibility
31
+
32
+ - **Never colour alone:** `KeyMetrics` keeps **icon + numeric delta**; `aria-label` on the chip uses **`metricTrendAriaQualifier`** (e.g. “increased, unfavorable +1”).
33
+ - **Decorative icons** stay `aria-hidden`; meaning lives in the chip’s **`aria-label`** and visible delta text.
34
+
35
+ ## Anti-patterns
36
+
37
+ - Forcing **`trend: "up"`** green because “up feels good” when the metric is **defects** or **flags** — set **`lower_is_better`** instead.
38
+ - Hiding a worsening metric by flipping the arrow without changing **`trend`** — arrows must match the data.
39
+ - Using **`informational`** for KPIs that **do** have an agreed quality bar — pick a polarity or omit the trend chip (`delta: "—"`, `trend: "neutral"`).
40
+
41
+ ## Related surfaces
42
+
43
+ - **`ChartCard`** `miniMetrics` / `kpi-chart` variant — optional **`trendPolarity`** on each mini metric; uses the same **`metricTrendTone`** helper from `key-metrics.tsx`.
@@ -14,7 +14,7 @@
14
14
  "iconCount": 166,
15
15
  "icons": [
16
16
  "arrow-down",
17
- "arrow-down-az",
17
+ "arrow-down-a-z",
18
18
  "arrow-down-to-line",
19
19
  "arrow-left",
20
20
  "arrow-left-to-line",
@@ -26,7 +26,7 @@
26
26
  "arrow-trend-up",
27
27
  "arrow-up",
28
28
  "arrow-up-arrow-down",
29
- "arrow-up-az",
29
+ "arrow-up-a-z",
30
30
  "arrow-up-right",
31
31
  "arrow-up-right-from-square",
32
32
  "arrows-rotate",
@@ -2,14 +2,20 @@
2
2
 
3
3
  import * as React from "react"
4
4
 
5
+ function subscribeHash(callback: () => void) {
6
+ window.addEventListener("hashchange", callback)
7
+ return () => window.removeEventListener("hashchange", callback)
8
+ }
9
+
10
+ function getHashSnapshot() {
11
+ return window.location.hash
12
+ }
13
+
14
+ function getServerHashSnapshot() {
15
+ return ""
16
+ }
17
+
5
18
  /** Current `window.location.hash` (including `#`), updated on `hashchange`. */
6
19
  export function useLocationHash(): string {
7
- const [hash, setHash] = React.useState("")
8
- React.useEffect(() => {
9
- const read = () => setHash(typeof window !== "undefined" ? window.location.hash : "")
10
- read()
11
- window.addEventListener("hashchange", read)
12
- return () => window.removeEventListener("hashchange", read)
13
- }, [])
14
- return hash
20
+ return React.useSyncExternalStore(subscribeHash, getHashSnapshot, getServerHashSnapshot)
15
21
  }
@@ -0,0 +1,98 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { usePathname, useRouter, useSearchParams } from "next/navigation"
5
+
6
+ import { useSecondaryPanel } from "@/components/secondary-panel"
7
+ import { QUESTION_BANK_HUB_FIND_PATH, QUESTION_BANK_LIBRARY_PATH, QUESTION_BANK_LIST_PATH } from "@/lib/question-bank-nav"
8
+
9
+ function rewriteLibraryCanonicalToDedicatedSurface(pathname: string, nextHref: string, hash: string): string {
10
+ if (!nextHref.startsWith(QUESTION_BANK_LIBRARY_PATH)) return `${nextHref}${hash}`
11
+ const tail = nextHref.slice(QUESTION_BANK_LIBRARY_PATH.length)
12
+ if (pathname === QUESTION_BANK_LIST_PATH) return `${QUESTION_BANK_LIST_PATH}${tail}${hash}`
13
+ if (pathname === QUESTION_BANK_HUB_FIND_PATH) return `${QUESTION_BANK_HUB_FIND_PATH}${tail}${hash}`
14
+ return `${nextHref}${hash}`
15
+ }
16
+
17
+ function questionBankSearchParamsEqual(a: URLSearchParams, b: URLSearchParams): boolean {
18
+ const keys = new Set([...a.keys(), ...b.keys()])
19
+ for (const k of keys) {
20
+ const av = a.getAll(k).join("\u0000")
21
+ const bv = b.getAll(k).join("\u0000")
22
+ if (av !== bv) return false
23
+ }
24
+ return true
25
+ }
26
+
27
+ export interface UseSecondaryPanelHubNavOptions<TNav> {
28
+ /** Primary hub pathname (e.g. `/question-bank/library`). */
29
+ hubPathname: string
30
+ /** When set, these pathnames are treated as the same hub (e.g. library + list search surface). */
31
+ hubPathnames?: readonly string[]
32
+ /** `PANELS` / `useAutoPanel` id (e.g. `question-bank`). */
33
+ panelId: string
34
+ parseNav: (searchParams: URLSearchParams) => TNav
35
+ /** When non-null, the hub URL is rewritten (keeps the current hash). */
36
+ canonicalHref?: (searchParams: URLSearchParams) => string | null
37
+ /** Re-open the secondary panel when the user returns to the default scope (e.g. All questions). */
38
+ shouldReopenPanel?: (nav: TNav) => boolean
39
+ }
40
+
41
+ /**
42
+ * URL scope for a primary hub with a nested secondary panel — shared between panel nav and main content.
43
+ */
44
+ export function useSecondaryPanelHubNav<TNav>({
45
+ hubPathname,
46
+ hubPathnames,
47
+ panelId,
48
+ parseNav,
49
+ canonicalHref,
50
+ shouldReopenPanel,
51
+ }: UseSecondaryPanelHubNavOptions<TNav>) {
52
+ const pathname = usePathname()
53
+ const router = useRouter()
54
+ const searchParams = useSearchParams()
55
+ const { openPanel, activePanel } = useSecondaryPanel()
56
+
57
+ const hubPaths = React.useMemo(
58
+ () => (hubPathnames?.length ? [...hubPathnames] : [hubPathname]),
59
+ [hubPathname, hubPathnames],
60
+ )
61
+ const isHubPath = hubPaths.includes(pathname)
62
+ const hubBasePath = hubPaths.includes(pathname) ? pathname : hubPathname
63
+
64
+ const searchParamsKey = searchParams.toString()
65
+ const navState = React.useMemo(
66
+ () => parseNav(new URLSearchParams(searchParamsKey)),
67
+ [parseNav, searchParamsKey],
68
+ )
69
+
70
+ React.useEffect(() => {
71
+ if (!isHubPath || !canonicalHref) return
72
+ const nextHref = canonicalHref(new URLSearchParams(searchParamsKey))
73
+ if (!nextHref) return
74
+ const hash = typeof window !== "undefined" ? window.location.hash : ""
75
+ let target = `${nextHref}${hash}`
76
+ if (pathname === QUESTION_BANK_LIST_PATH || pathname === QUESTION_BANK_HUB_FIND_PATH) {
77
+ target = rewriteLibraryCanonicalToDedicatedSurface(pathname, nextHref, hash)
78
+ }
79
+ try {
80
+ const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost"
81
+ const u = new URL(target, origin)
82
+ const want = u.searchParams
83
+ const cur = new URLSearchParams(searchParamsKey)
84
+ if (u.pathname === pathname && questionBankSearchParamsEqual(want, cur)) return
85
+ } catch {
86
+ /* ignore parse errors — fall through to replace */
87
+ }
88
+ router.replace(target, { scroll: false })
89
+ }, [canonicalHref, isHubPath, pathname, router, searchParamsKey])
90
+
91
+ React.useEffect(() => {
92
+ if (!isHubPath || !shouldReopenPanel?.(navState)) return
93
+ if (activePanel === panelId) return
94
+ openPanel(panelId)
95
+ }, [activePanel, isHubPath, navState, openPanel, panelId, shouldReopenPanel])
96
+
97
+ return { navState, searchParamsKey, hubPathname, hubBasePath, pathname, isHubPath }
98
+ }
@@ -78,6 +78,30 @@ export function getAskLeoRouteContext(pathname: string | null): AskLeoRouteConte
78
78
  }
79
79
  }
80
80
 
81
+ if (pathname.startsWith("/question-bank/library") || pathname.startsWith("/question-bank/list") || pathname.startsWith("/question-bank/find")) {
82
+ return {
83
+ title: "Question library",
84
+ description: "Browse folders, views, and mock assessment items.",
85
+ suggestions: [
86
+ "Summarize questions in the active folder scope",
87
+ "Suggest folders for a new pediatrics module",
88
+ "How do panel and tree views relate to the same dataset?",
89
+ ],
90
+ }
91
+ }
92
+
93
+ if (pathname.startsWith("/question-bank")) {
94
+ return {
95
+ title: "Question bank",
96
+ description: "Search in plain language, draft items with AI, or open the full library.",
97
+ suggestions: [
98
+ "Draft a multiple-choice question on clinical reasoning",
99
+ "Outline a new question bank for a course module",
100
+ "Rewrite this stem for clarity and bias-free wording",
101
+ ],
102
+ }
103
+ }
104
+
81
105
  if (pathname.startsWith("/help")) {
82
106
  return {
83
107
  title: "Get help",
@@ -0,0 +1,92 @@
1
+ /** Library access roles for shared hubs — see `docs/collaboration-access-pattern.md`. */
2
+ export type CollaboratorAccessRole = "owner" | "editor" | "commenter" | "viewer"
3
+
4
+ export const COLLABORATOR_ACCESS_LABELS: Record<CollaboratorAccessRole, string> = {
5
+ owner: "Owner",
6
+ editor: "Editor",
7
+ commenter: "Commenter",
8
+ viewer: "Viewer",
9
+ }
10
+
11
+ /** Decorative FA icon names (`fa-light fa-*`) for library access chips and menus. */
12
+ export const COLLABORATOR_ACCESS_ICON_LIGHT: Record<CollaboratorAccessRole, string> = {
13
+ owner: "fa-crown",
14
+ editor: "fa-pen-to-square",
15
+ commenter: "fa-comment",
16
+ viewer: "fa-eye",
17
+ }
18
+
19
+ export type InviteCollaboratorAccessRole = Exclude<CollaboratorAccessRole, "owner">
20
+
21
+ export const INVITE_COLLABORATOR_ACCESS_OPTIONS: {
22
+ value: InviteCollaboratorAccessRole
23
+ label: string
24
+ description: string
25
+ }[] = [
26
+ {
27
+ value: "editor",
28
+ label: "Editor",
29
+ description: "Can add and edit questions in this library.",
30
+ },
31
+ {
32
+ value: "commenter",
33
+ label: "Commenter",
34
+ description: "Can review and comment without editing.",
35
+ },
36
+ {
37
+ value: "viewer",
38
+ label: "Viewer",
39
+ description: "Can view questions only.",
40
+ },
41
+ ]
42
+
43
+ /** Roster access menu — includes Owner for existing collaborators. */
44
+ export const ROSTER_COLLABORATOR_ACCESS_OPTIONS: {
45
+ value: CollaboratorAccessRole
46
+ label: string
47
+ }[] = (["owner", "editor", "commenter", "viewer"] as const).map(value => ({
48
+ value,
49
+ label: COLLABORATOR_ACCESS_LABELS[value],
50
+ }))
51
+
52
+ export function rosterOwnerCount(collaborators: readonly { access?: CollaboratorAccessRole }[]) {
53
+ return collaborators.filter(person => person.access === "owner").length
54
+ }
55
+
56
+ export function canRemoveCollaboratorFromRoster(
57
+ person: { access?: CollaboratorAccessRole },
58
+ collaborators: readonly { access?: CollaboratorAccessRole }[],
59
+ ) {
60
+ if (person.access !== "owner") return true
61
+ return rosterOwnerCount(collaborators) > 1
62
+ }
63
+
64
+ export function canSetCollaboratorAccessRole(
65
+ person: { access?: CollaboratorAccessRole },
66
+ collaborators: readonly { access?: CollaboratorAccessRole }[],
67
+ nextAccess: CollaboratorAccessRole,
68
+ ) {
69
+ if (person.access === nextAccess) return false
70
+ if (person.access === "owner" && rosterOwnerCount(collaborators) === 1 && nextAccess !== "owner") {
71
+ return false
72
+ }
73
+ return true
74
+ }
75
+
76
+ export function collaboratorRemoveBlockedReason(
77
+ person: { access?: CollaboratorAccessRole },
78
+ collaborators: readonly { access?: CollaboratorAccessRole }[],
79
+ ) {
80
+ if (canRemoveCollaboratorFromRoster(person, collaborators)) return undefined
81
+ return "This person is the only owner."
82
+ }
83
+
84
+ /** Default label for the collaboration header when the roster is empty. */
85
+ export const COLLABORATION_HEADER_ADD_LABEL = "Add collaborator"
86
+
87
+ export function displayNameFromInviteEmail(email: string) {
88
+ const local = email.split("@")[0] ?? email
89
+ return local
90
+ .replace(/[._-]+/g, " ")
91
+ .replace(/\b\w/g, char => char.toUpperCase())
92
+ }
@@ -99,7 +99,14 @@ const STATIC_COMMAND_GROUPS: CommandMenuGroup[] = [
99
99
  icon: "fa-light fa-books",
100
100
  label: "Question bank",
101
101
  href: "/question-bank",
102
- keywords: "folders assessment items tree panel",
102
+ keywords: "search ai create ask leo discovery hub",
103
+ },
104
+ {
105
+ id: "nav-question-bank-library",
106
+ icon: "fa-light fa-table-list",
107
+ label: "Question library",
108
+ href: "/question-bank/library",
109
+ keywords: "folders assessment items tree panel table",
103
110
  },
104
111
  { id: "nav-data-list", icon: "fa-light fa-table", label: "List hub", href: "/data-list" },
105
112
  { id: "nav-settings", icon: "fa-light fa-gear", label: "Settings", href: "/settings" },
@@ -32,14 +32,17 @@ function sampleRowSearchItems(): CommandMenuItem[] {
32
32
  })
33
33
  }
34
34
 
35
+ /** Built once at module load — avoids remapping all placement rows on every layout render. */
36
+ export const COMMAND_MENU_SEARCH_DATA_GROUPS: CommandMenuGroup[] = [
37
+ {
38
+ id: "sample-rows",
39
+ heading: "Demo rows",
40
+ items: sampleRowSearchItems(),
41
+ searchOnly: true,
42
+ },
43
+ ]
44
+
35
45
  /** Demo rows for the list hub — search-only so the palette stays lightweight on open. */
36
46
  export function getCommandMenuSearchDataGroups(): CommandMenuGroup[] {
37
- return [
38
- {
39
- id: "sample-rows",
40
- heading: "Demo rows",
41
- items: sampleRowSearchItems(),
42
- searchOnly: true,
43
- },
44
- ]
47
+ return COMMAND_MENU_SEARCH_DATA_GROUPS
45
48
  }
@@ -25,7 +25,7 @@ export interface DataListDisplayOptions {
25
25
  }
26
26
 
27
27
  export const DEFAULT_DATA_LIST_DISPLAY_OPTIONS: DataListDisplayOptions = {
28
- boardGroupByColumnKey: "status",
28
+ boardGroupByColumnKey: "topic",
29
29
  boardLineCount: 2,
30
30
  showViewTitle: true,
31
31
  showColumnLabels: true,
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Placements **Data** view dashboard — card ids, defaults, and persistence.
3
+ * Split from `data-view-dashboard-charts.tsx` so list-hub code can import layout
4
+ * without pulling Recharts / chart canvas into the main chunk.
5
+ */
6
+
7
+ import {
8
+ KEY_METRICS_KPI_COUNT_DEFAULT,
9
+ mergeDashboardLayoutGeneric,
10
+ } from "@/lib/dashboard-layout-merge"
11
+ import {
12
+ loadDataViewLayout as loadStoredDataViewLayout,
13
+ saveDataViewLayout as saveStoredDataViewLayout,
14
+ } from "@/lib/data-view-dashboard-storage"
15
+
16
+ export type ChartType =
17
+ | "bar"
18
+ | "horizontal-bar"
19
+ | "pie"
20
+ | "area"
21
+ | "line"
22
+ | "radial"
23
+ | "stacked-bar"
24
+
25
+ export interface ChartTypeOption {
26
+ type: ChartType
27
+ label: string
28
+ icon: string
29
+ }
30
+
31
+ export interface DashboardCardDef {
32
+ id: string
33
+ title: string
34
+ description: string
35
+ defaultSpan: 1 | 2
36
+ defaultChartType: ChartType
37
+ chartTypes: ChartTypeOption[]
38
+ }
39
+
40
+ /** Virtual “card” for the KPI strip — reorderable with charts in edit mode */
41
+ export const KEY_METRICS_CARD_ID = "key-metrics"
42
+
43
+ export const ALL_DASHBOARD_CARDS: DashboardCardDef[] = [
44
+ {
45
+ id: KEY_METRICS_CARD_ID,
46
+ title: "Key metrics",
47
+ description: "Summary KPIs for filtered placements",
48
+ defaultSpan: 1,
49
+ defaultChartType: "bar",
50
+ chartTypes: [],
51
+ },
52
+ {
53
+ id: "status-pipeline",
54
+ title: "Status Pipeline",
55
+ description: "Where placements are in the workflow",
56
+ defaultSpan: 1,
57
+ defaultChartType: "bar",
58
+ chartTypes: [
59
+ { type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
60
+ { type: "horizontal-bar", label: "Horizontal Bar", icon: "fa-light fa-chart-bar fa-rotate-90" },
61
+ { type: "pie", label: "Donut", icon: "fa-light fa-chart-pie" },
62
+ ],
63
+ },
64
+ {
65
+ id: "program-mix",
66
+ title: "Placements by Program",
67
+ description: "Distribution across active programs",
68
+ defaultSpan: 1,
69
+ defaultChartType: "pie",
70
+ chartTypes: [
71
+ { type: "pie", label: "Donut", icon: "fa-light fa-chart-pie" },
72
+ { type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
73
+ { type: "horizontal-bar", label: "Horizontal Bar", icon: "fa-light fa-chart-bar fa-rotate-90" },
74
+ ],
75
+ },
76
+ {
77
+ id: "compliance-status",
78
+ title: "Compliance Status",
79
+ description: "Document readiness for upcoming placements",
80
+ defaultSpan: 1,
81
+ defaultChartType: "horizontal-bar",
82
+ chartTypes: [
83
+ { type: "horizontal-bar", label: "Horizontal Bar", icon: "fa-light fa-chart-bar fa-rotate-90" },
84
+ { type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
85
+ { type: "pie", label: "Donut", icon: "fa-light fa-chart-pie" },
86
+ ],
87
+ },
88
+ {
89
+ id: "readiness-overview",
90
+ title: "Student Readiness",
91
+ description: "How prepared students are for their placements",
92
+ defaultSpan: 1,
93
+ defaultChartType: "bar",
94
+ chartTypes: [
95
+ { type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
96
+ { type: "horizontal-bar", label: "Horizontal Bar", icon: "fa-light fa-chart-bar fa-rotate-90" },
97
+ { type: "pie", label: "Donut", icon: "fa-light fa-chart-pie" },
98
+ ],
99
+ },
100
+ {
101
+ id: "progress-tracker",
102
+ title: "Ongoing Progress",
103
+ description: "How far along each ongoing placement is",
104
+ defaultSpan: 2,
105
+ defaultChartType: "stacked-bar",
106
+ chartTypes: [
107
+ { type: "stacked-bar", label: "Stacked Bar", icon: "fa-light fa-layer-group" },
108
+ { type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
109
+ ],
110
+ },
111
+ {
112
+ id: "site-utilisation",
113
+ title: "Site Utilisation",
114
+ description: "Which clinical sites have the most placements",
115
+ defaultSpan: 1,
116
+ defaultChartType: "horizontal-bar",
117
+ chartTypes: [
118
+ { type: "horizontal-bar", label: "Horizontal Bar", icon: "fa-light fa-chart-bar fa-rotate-90" },
119
+ { type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
120
+ { type: "pie", label: "Donut", icon: "fa-light fa-chart-pie" },
121
+ ],
122
+ },
123
+ {
124
+ id: "completion-outcomes",
125
+ title: "Completion Outcomes",
126
+ description: "Pass rate and average ratings for completed placements",
127
+ defaultSpan: 1,
128
+ defaultChartType: "radial",
129
+ chartTypes: [
130
+ { type: "radial", label: "Radial", icon: "fa-light fa-circle-notch" },
131
+ { type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
132
+ ],
133
+ },
134
+ {
135
+ id: "upcoming-timeline",
136
+ title: "Upcoming Start Dates",
137
+ description: "New placements starting in the next 8 weeks",
138
+ defaultSpan: 2,
139
+ defaultChartType: "area",
140
+ chartTypes: [
141
+ { type: "area", label: "Area", icon: "fa-light fa-chart-area" },
142
+ { type: "line", label: "Line", icon: "fa-light fa-chart-line" },
143
+ { type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
144
+ ],
145
+ },
146
+ ]
147
+
148
+ export const DEFAULT_VISIBLE_CARDS = ALL_DASHBOARD_CARDS.map(c => c.id)
149
+ export const DEFAULT_SPANS: Record<string, 1 | 2> = Object.fromEntries(
150
+ ALL_DASHBOARD_CARDS.map(c => [c.id, c.defaultSpan]),
151
+ )
152
+ export const DEFAULT_CHART_TYPES: Record<string, ChartType> = Object.fromEntries(
153
+ ALL_DASHBOARD_CARDS.map(c => [c.id, c.defaultChartType]),
154
+ )
155
+
156
+ export interface DashboardLayout {
157
+ visible: string[]
158
+ order: string[]
159
+ spans?: Record<string, 1 | 2>
160
+ chartTypes?: Record<string, ChartType>
161
+ keyMetricsKpiCount?: number
162
+ }
163
+
164
+ export function loadDashboardLayout(): DashboardLayout | null {
165
+ const v = loadStoredDataViewLayout("placements")
166
+ if (!v) return null
167
+ return {
168
+ visible: v.visible,
169
+ order: v.order,
170
+ spans: v.spans,
171
+ chartTypes: v.chartTypes as Record<string, ChartType> | undefined,
172
+ keyMetricsKpiCount: v.keyMetricsKpiCount,
173
+ }
174
+ }
175
+
176
+ export function mergeDashboardLayout(saved: DashboardLayout | null): DashboardLayout {
177
+ const defaults = {
178
+ visible: [...DEFAULT_VISIBLE_CARDS],
179
+ order: ALL_DASHBOARD_CARDS.map(c => c.id),
180
+ spans: { ...DEFAULT_SPANS },
181
+ chartTypes: { ...DEFAULT_CHART_TYPES } as Record<string, string>,
182
+ keyMetricsKpiCount: KEY_METRICS_KPI_COUNT_DEFAULT,
183
+ }
184
+ const ids = ALL_DASHBOARD_CARDS.map(c => c.id)
185
+ const m = mergeDashboardLayoutGeneric(saved, defaults, ids)
186
+ return {
187
+ visible: m.visible,
188
+ order: m.order,
189
+ spans: m.spans as Record<string, 1 | 2>,
190
+ chartTypes: m.chartTypes as Record<string, ChartType>,
191
+ keyMetricsKpiCount: m.keyMetricsKpiCount,
192
+ }
193
+ }
194
+
195
+ export function saveDashboardLayout(layout: DashboardLayout) {
196
+ saveStoredDataViewLayout("placements", {
197
+ visible: layout.visible,
198
+ order: layout.order,
199
+ spans: layout.spans,
200
+ chartTypes: layout.chartTypes as Record<string, string> | undefined,
201
+ keyMetricsKpiCount: layout.keyMetricsKpiCount,
202
+ })
203
+ }
204
+
205
+ export function applyVisibleReorder(
206
+ fullOrder: string[],
207
+ visible: Set<string>,
208
+ newVisibleOrder: string[],
209
+ ): string[] {
210
+ let vi = 0
211
+ return fullOrder.map(id => {
212
+ if (!visible.has(id)) return id
213
+ return newVisibleOrder[vi++]!
214
+ })
215
+ }
@@ -1,5 +1,6 @@
1
1
  export {
2
2
  formatDateUS,
3
+ formatDateFromDate,
3
4
  formatDateTimeUS,
4
5
  parseRowDateToYmd,
5
6
  formatYmdForDisplay,