@umituz/web-dashboard 6.8.0 → 6.10.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/web-dashboard",
3
- "version": "6.8.0",
3
+ "version": "6.10.0",
4
4
  "description": "Installable app skeleton for the fleet — layouts, analytics (dependency-free SVG charts), calendar, onboarding flow, i18n via @umituz/web-localization, permissions/module registry; udb-* stylesheet on design-system tokens",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -39,6 +39,10 @@
39
39
  "./settings/hooks": "./src/domains/settings/hooks/index.ts",
40
40
  "./settings/types": "./src/domains/settings/types/index.ts",
41
41
  "./settings/utils": "./src/domains/settings/utils/index.ts",
42
+ "./timing": "./src/domains/timing/index.ts",
43
+ "./timing/hooks": "./src/domains/timing/hooks/index.ts",
44
+ "./search": "./src/domains/search/index.ts",
45
+ "./search/utils": "./src/domains/search/searchItems.ts",
42
46
  "./styles.css": "./src/styles/dashboard.css"
43
47
  },
44
48
  "files": [
@@ -98,4 +98,28 @@ describe("DashboardLayout (command palette)", () => {
98
98
  expect(onActivate).toHaveBeenCalledTimes(1);
99
99
  expect(screen.queryByRole("dialog")).toBeNull();
100
100
  });
101
+
102
+ it("derives navigation groups from the sidebar when includeSidebar is set", async () => {
103
+ renderAt("/", undefined, {
104
+ ...config,
105
+ commandPalette: {
106
+ includeSidebar: true,
107
+ groups: [{ heading: "Quick", items: [{ id: "q", label: "Quick action", onSelect: () => {} }] }],
108
+ },
109
+ });
110
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
111
+ expect(await screen.findByRole("option", { name: "Quick action" })).toBeTruthy();
112
+ // The custom group has no "Home" — the one present is derived from the sidebar,
113
+ // carrying the path as its hint (accessible name "Home /").
114
+ const derived = screen.getByRole("option", { name: /home/i });
115
+ expect(derived.textContent).toContain("/");
116
+ });
117
+
118
+ it("omits sidebar-derived items from the palette by default", async () => {
119
+ renderAt("/", undefined, paletteConfig());
120
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
121
+ // Only the palette's own "Home" (no path hint) — nothing derived from the sidebar.
122
+ const own = await screen.findByRole("option", { name: "Home" });
123
+ expect(own.textContent).toBe("Home");
124
+ });
101
125
  });
@@ -1,5 +1,5 @@
1
1
  import { useState, useEffect, type ReactNode } from "react";
2
- import { useLocation, Outlet, Navigate } from "react-router-dom";
2
+ import { useLocation, useNavigate, Outlet, Navigate } from "react-router-dom";
3
3
  import { useT } from "@umituz/web-localization/react";
4
4
  import { Skeleton } from "@umituz/web-design-system/atoms";
5
5
  import { CommandPalette } from "@umituz/web-design-system/organisms";
@@ -61,6 +61,7 @@ export const DashboardLayout = ({
61
61
  skipToContentLabel,
62
62
  }: DashboardLayoutProps) => {
63
63
  const location = useLocation();
64
+ const navigate = useNavigate();
64
65
  const t = useT();
65
66
  const [collapsed, setCollapsed] = useState(() => readCollapsed(collapsedStorageKey));
66
67
  const [mobileOpen, setMobileOpen] = useState(false);
@@ -132,6 +133,23 @@ export const DashboardLayout = ({
132
133
 
133
134
  const currentTitle = getTitle();
134
135
 
136
+ // Sidebar-derived navigation groups — one nav definition feeds both chrome surfaces.
137
+ const paletteGroups = palette?.includeSidebar
138
+ ? [
139
+ ...palette.groups,
140
+ ...config.sidebarGroups.map((group) => ({
141
+ heading: group.title,
142
+ items: group.items.map((item) => ({
143
+ id: `nav-${item.path}`,
144
+ label: item.raw ? item.label : t(item.label),
145
+ icon: <item.icon size={16} aria-hidden="true" />,
146
+ hint: item.path,
147
+ onSelect: () => navigate(item.path),
148
+ })),
149
+ })),
150
+ ]
151
+ : undefined;
152
+
135
153
  const sidebarProps = {
136
154
  sidebarGroups: config.sidebarGroups,
137
155
  brandName: config.brandName,
@@ -225,7 +243,7 @@ export const DashboardLayout = ({
225
243
  <CommandPalette
226
244
  open={paletteOpen}
227
245
  onOpenChange={setPaletteOpen}
228
- groups={palette.groups}
246
+ groups={paletteGroups ?? palette.groups}
229
247
  placeholder={palette.placeholder}
230
248
  emptyLabel={palette.emptyLabel}
231
249
  label={palette.label}
@@ -42,6 +42,9 @@ export interface CommandPaletteConfig {
42
42
  label?: string;
43
43
  /** Bind ⌘K/Ctrl+K to open the palette (default true). */
44
44
  shortcut?: boolean;
45
+ /** Append the sidebar groups as navigation groups — derived from
46
+ * sidebarGroups and routed internally, so apps never map their nav twice. */
47
+ includeSidebar?: boolean;
45
48
  }
46
49
 
47
50
  export interface DashboardSidebarProps {
@@ -0,0 +1,2 @@
1
+ export { searchItems } from './searchItems';
2
+ export type { SearchHit } from './searchItems';
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { searchItems } from './searchItems';
3
+
4
+ interface Course {
5
+ id: string;
6
+ name: string;
7
+ blurb: string;
8
+ level: string;
9
+ }
10
+
11
+ const COURSES: Course[] = [
12
+ { id: 'c1', name: 'Present perfect workshop', blurb: 'Have you ever…', level: 'B1' },
13
+ { id: 'c2', name: 'IELTS reading sprint', blurb: 'Skimming strategies', level: 'C1' },
14
+ { id: 'c3', name: 'Everyday vocabulary', blurb: 'Present simple review', level: 'A1' },
15
+ ];
16
+
17
+ const toHit = (c: Course) => ({ id: c.id, title: c.name, subtitle: c.blurb, category: c.level, to: `/courses/${c.id}` });
18
+
19
+ describe('searchItems', () => {
20
+ it('returns nothing for empty or blank queries', () => {
21
+ expect(searchItems(COURSES, '', toHit)).toEqual([]);
22
+ expect(searchItems(COURSES, ' ', toHit)).toEqual([]);
23
+ });
24
+
25
+ it('matches titles case-insensitively and returns route hits', () => {
26
+ const hits = searchItems(COURSES, 'ielts', toHit);
27
+ expect(hits).toHaveLength(1);
28
+ expect(hits[0]).toMatchObject({ id: 'c2', title: 'IELTS reading sprint', to: '/courses/c2' });
29
+ });
30
+
31
+ it('ranks title matches above body-only matches', () => {
32
+ // "present" is in c1's title and c3's blurb — title wins.
33
+ const hits = searchItems(COURSES, 'present', toHit);
34
+ expect(hits[0]?.id).toBe('c1');
35
+ expect(hits).toHaveLength(2);
36
+ });
37
+
38
+ it('scores multi-term queries across fields', () => {
39
+ const hits = searchItems(COURSES, 'reading strategies', toHit);
40
+ expect(hits[0]?.id).toBe('c2');
41
+ });
42
+ });
@@ -0,0 +1,36 @@
1
+ export interface SearchHit {
2
+ id: string;
3
+ title: string;
4
+ subtitle: string;
5
+ category: string;
6
+ to: string;
7
+ }
8
+
9
+ /** Case-insensitive substring scoring across a haystack of fields.
10
+ * Title matches weigh 3×; every additional field hit adds 1. Empty
11
+ * queries return no hits. Results are sorted by descending score. */
12
+ export function searchItems<T extends object>(
13
+ items: readonly T[],
14
+ query: string,
15
+ toHit: (item: T) => Omit<SearchHit, 'id'> & { id?: string } & Record<string, unknown>,
16
+ ): SearchHit[] {
17
+ const q = query.trim().toLowerCase();
18
+ if (!q) return [];
19
+ const terms = q.split(/\s+/);
20
+ const hits: { hit: SearchHit; score: number }[] = [];
21
+ for (const item of items) {
22
+ const built = toHit(item);
23
+ const haystack =
24
+ `${built.title} ${built.subtitle} ${built.category} ${Object.values(item).filter((v) => typeof v === 'string').join(' ')}`.toLowerCase();
25
+ let score = 0;
26
+ for (const term of terms) {
27
+ if (built.title.toLowerCase().includes(term)) score += 3;
28
+ if (haystack.includes(term)) score += 1;
29
+ }
30
+ if (score > 0) {
31
+ const { title, subtitle, category, to, ...rest } = built;
32
+ hits.push({ hit: { id: (rest.id as string) ?? title, title, subtitle, category, to }, score });
33
+ }
34
+ }
35
+ return hits.sort((a, b) => b.score - a.score).map((h) => h.hit);
36
+ }
@@ -0,0 +1 @@
1
+ export { useCountdown, useStopwatch } from './useCountdown';
@@ -0,0 +1,107 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { act, renderHook } from '@testing-library/react';
3
+ import { useCountdown, useStopwatch } from './useCountdown';
4
+
5
+ describe('useCountdown', () => {
6
+ beforeEach(() => {
7
+ vi.useFakeTimers();
8
+ });
9
+ afterEach(() => {
10
+ vi.useRealTimers();
11
+ });
12
+
13
+ it('ticks down once per second while running', () => {
14
+ const { result } = renderHook(() => useCountdown(3));
15
+ expect(result.current.remaining).toBe(3);
16
+ act(() => {
17
+ vi.advanceTimersByTime(2000);
18
+ });
19
+ expect(result.current.remaining).toBe(1);
20
+ expect(result.current.running).toBe(true);
21
+ });
22
+
23
+ it('fires onFinish exactly once at zero and stops', () => {
24
+ const onFinish = vi.fn();
25
+ const { result } = renderHook(() => useCountdown(2, onFinish));
26
+ act(() => {
27
+ vi.advanceTimersByTime(2000);
28
+ });
29
+ expect(result.current.remaining).toBe(0);
30
+ expect(result.current.running).toBe(false);
31
+ expect(onFinish).toHaveBeenCalledTimes(1);
32
+ act(() => {
33
+ vi.advanceTimersByTime(5000);
34
+ });
35
+ expect(onFinish).toHaveBeenCalledTimes(1);
36
+ });
37
+
38
+ it('restart rewinds the clock and re-arms completion', () => {
39
+ const onFinish = vi.fn();
40
+ const { result } = renderHook(() => useCountdown(1, onFinish));
41
+ act(() => {
42
+ vi.advanceTimersByTime(1000);
43
+ });
44
+ expect(onFinish).toHaveBeenCalledTimes(1);
45
+ act(() => {
46
+ result.current.restart();
47
+ });
48
+ expect(result.current.remaining).toBe(1);
49
+ expect(result.current.running).toBe(true);
50
+ act(() => {
51
+ vi.advanceTimersByTime(1000);
52
+ });
53
+ expect(onFinish).toHaveBeenCalledTimes(2);
54
+ });
55
+
56
+ it('pause halts the tick and start resumes it', () => {
57
+ const { result } = renderHook(() => useCountdown(5));
58
+ // state change and time travel must be separate acts — the interval
59
+ // effect only re-registers after the pause() commit
60
+ act(() => {
61
+ result.current.pause();
62
+ });
63
+ act(() => {
64
+ vi.advanceTimersByTime(3000);
65
+ });
66
+ expect(result.current.remaining).toBe(5);
67
+ act(() => {
68
+ result.current.start();
69
+ });
70
+ act(() => {
71
+ vi.advanceTimersByTime(1000);
72
+ });
73
+ expect(result.current.remaining).toBe(4);
74
+ });
75
+ });
76
+
77
+ describe('useStopwatch', () => {
78
+ beforeEach(() => {
79
+ vi.useFakeTimers();
80
+ });
81
+ afterEach(() => {
82
+ vi.useRealTimers();
83
+ });
84
+
85
+ it('counts up after start and freezes on stop', () => {
86
+ const { result } = renderHook(() => useStopwatch());
87
+ expect(result.current.elapsed).toBe(0);
88
+ act(() => {
89
+ result.current.start();
90
+ });
91
+ act(() => {
92
+ vi.advanceTimersByTime(3000);
93
+ });
94
+ expect(result.current.elapsed).toBe(3);
95
+ act(() => {
96
+ result.current.stop();
97
+ });
98
+ act(() => {
99
+ vi.advanceTimersByTime(3000);
100
+ });
101
+ expect(result.current.elapsed).toBe(3);
102
+ act(() => {
103
+ result.current.reset();
104
+ });
105
+ expect(result.current.elapsed).toBe(0);
106
+ });
107
+ });
@@ -0,0 +1,75 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ /** Ticking countdown in seconds; fires onFinish once at zero. */
4
+ export function useCountdown(seconds: number | null, onFinish?: () => void) {
5
+ const [remaining, setRemaining] = useState(seconds ?? 0);
6
+ const [running, setRunning] = useState(seconds != null);
7
+ const finishedRef = useRef(false);
8
+ const onFinishRef = useRef(onFinish);
9
+ onFinishRef.current = onFinish;
10
+
11
+ useEffect(() => {
12
+ setRemaining(seconds ?? 0);
13
+ setRunning(seconds != null);
14
+ finishedRef.current = false;
15
+ }, [seconds]);
16
+
17
+ const restart = () => {
18
+ finishedRef.current = false;
19
+ setRemaining(seconds ?? 0);
20
+ setRunning(seconds != null);
21
+ };
22
+
23
+ useEffect(() => {
24
+ if (!running) return;
25
+ const id = window.setInterval(() => {
26
+ setRemaining((r) => (r <= 1 ? 0 : r - 1));
27
+ }, 1000);
28
+ return () => window.clearInterval(id);
29
+ }, [running]);
30
+
31
+ // Completion fires in an effect, not inside the setRemaining updater —
32
+ // updaters must stay pure (StrictMode double-invokes them).
33
+ useEffect(() => {
34
+ if (running && remaining === 0 && !finishedRef.current) {
35
+ finishedRef.current = true;
36
+ setRunning(false);
37
+ onFinishRef.current?.();
38
+ }
39
+ }, [running, remaining]);
40
+
41
+ return {
42
+ remaining,
43
+ running,
44
+ start: () => setRunning(true),
45
+ pause: () => setRunning(false),
46
+ stop: () => {
47
+ setRunning(false);
48
+ finishedRef.current = true;
49
+ },
50
+ restart,
51
+ };
52
+ }
53
+
54
+ /** Stopwatch for open practice (writing time, speaking response windows). */
55
+ export function useStopwatch() {
56
+ const [elapsed, setElapsed] = useState(0);
57
+ const [running, setRunning] = useState(false);
58
+
59
+ useEffect(() => {
60
+ if (!running) return;
61
+ const id = window.setInterval(() => setElapsed((e) => e + 1), 1000);
62
+ return () => window.clearInterval(id);
63
+ }, [running]);
64
+
65
+ return {
66
+ elapsed,
67
+ running,
68
+ start: () => setRunning(true),
69
+ stop: () => setRunning(false),
70
+ reset: () => {
71
+ setRunning(false);
72
+ setElapsed(0);
73
+ },
74
+ };
75
+ }
@@ -0,0 +1 @@
1
+ export * from './hooks';