@umituz/web-dashboard 6.9.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 +5 -1
- package/src/domains/search/index.ts +2 -0
- package/src/domains/search/searchItems.test.ts +42 -0
- package/src/domains/search/searchItems.ts +36 -0
- package/src/domains/timing/hooks/index.ts +1 -0
- package/src/domains/timing/hooks/useCountdown.test.ts +107 -0
- package/src/domains/timing/hooks/useCountdown.ts +75 -0
- package/src/domains/timing/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/web-dashboard",
|
|
3
|
-
"version": "6.
|
|
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": [
|
|
@@ -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';
|