github-issue-tower-defence-management 1.181.1 → 1.183.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/adapter/entry-points/console/ui-dist/assets/index-8Ni5iu-C.css +2 -0
  3. package/bin/adapter/entry-points/console/ui-dist/assets/index-Cc2LSARI.js +86 -0
  4. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  5. package/bin/adapter/entry-points/console/webServer.js +6 -0
  6. package/bin/adapter/entry-points/console/webServer.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +74 -0
  9. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.stories.tsx +7 -0
  10. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.test.tsx +47 -0
  11. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.tsx +9 -2
  12. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.stories.tsx +23 -0
  13. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.test.tsx +25 -0
  14. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.tsx +6 -0
  15. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTimerSettingsModalDialog.stories.tsx +49 -0
  16. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTimerSettingsModalDialog.test.tsx +157 -0
  17. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTimerSettingsModalDialog.tsx +118 -0
  18. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleProjectList.test.ts +74 -0
  19. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleProjectList.ts +40 -0
  20. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleProjectTimer.test.ts +59 -0
  21. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleProjectTimer.ts +26 -0
  22. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTimerSettings.test.ts +110 -0
  23. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTimerSettings.ts +70 -0
  24. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +22 -0
  25. package/src/adapter/entry-points/console/ui/src/features/console/lib/navigation.ts +7 -0
  26. package/src/adapter/entry-points/console/ui/src/features/console/logic/timerSettings.test.ts +61 -0
  27. package/src/adapter/entry-points/console/ui/src/features/console/logic/timerSettings.ts +77 -0
  28. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +6 -0
  29. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +178 -0
  30. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +89 -2
  31. package/src/adapter/entry-points/console/ui/src/index.css +139 -0
  32. package/src/adapter/entry-points/console/ui-dist/assets/index-8Ni5iu-C.css +2 -0
  33. package/src/adapter/entry-points/console/ui-dist/assets/index-Cc2LSARI.js +86 -0
  34. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  35. package/src/adapter/entry-points/console/webServer.test.ts +93 -0
  36. package/src/adapter/entry-points/console/webServer.ts +6 -0
  37. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  38. package/bin/adapter/entry-points/console/ui-dist/assets/index-BUFcVUVD.js +0 -86
  39. package/bin/adapter/entry-points/console/ui-dist/assets/index-DeZcPNCr.css +0 -2
  40. package/src/adapter/entry-points/console/ui-dist/assets/index-BUFcVUVD.js +0 -86
  41. package/src/adapter/entry-points/console/ui-dist/assets/index-DeZcPNCr.css +0 -2
@@ -0,0 +1,118 @@
1
+ export type ConsoleTimerSettingsModalDialogProps = {
2
+ isOpen: boolean;
3
+ timerMode: boolean;
4
+ projectMinutes: Record<string, number>;
5
+ pjcodes: string[];
6
+ isLoadingPjcodes: boolean;
7
+ onOpen: () => void;
8
+ onToggleTimerMode: (enabled: boolean) => void;
9
+ onChangeMinutes: (pjcode: string, minutes: number) => void;
10
+ onSave: () => void;
11
+ onClose: () => void;
12
+ };
13
+
14
+ export const ConsoleTimerSettingsModalDialog = ({
15
+ isOpen,
16
+ timerMode,
17
+ projectMinutes,
18
+ pjcodes,
19
+ isLoadingPjcodes,
20
+ onOpen,
21
+ onToggleTimerMode,
22
+ onChangeMinutes,
23
+ onSave,
24
+ onClose,
25
+ }: ConsoleTimerSettingsModalDialogProps) => {
26
+ return (
27
+ <>
28
+ <button
29
+ type="button"
30
+ className="console-timer-settings-button"
31
+ aria-label="Console Settings"
32
+ aria-haspopup="dialog"
33
+ aria-expanded={isOpen}
34
+ onClick={onOpen}
35
+ >
36
+ ⚙
37
+ </button>
38
+ {isOpen && (
39
+ <div
40
+ className="console-timer-settings-dialog"
41
+ role="dialog"
42
+ aria-modal="true"
43
+ aria-label="Console Settings"
44
+ >
45
+ <button
46
+ type="button"
47
+ className="console-timer-settings-backdrop"
48
+ aria-label="Close settings"
49
+ onClick={onClose}
50
+ />
51
+ <div className="console-timer-settings-dialog-inner">
52
+ <h2 className="console-timer-settings-title">Console Settings</h2>
53
+ <div className="console-timer-settings-row">
54
+ <label
55
+ className="console-timer-settings-label"
56
+ htmlFor="timer-mode-toggle"
57
+ >
58
+ Timer Mode
59
+ </label>
60
+ <input
61
+ id="timer-mode-toggle"
62
+ type="checkbox"
63
+ checked={timerMode}
64
+ onChange={(e) => onToggleTimerMode(e.target.checked)}
65
+ />
66
+ </div>
67
+ {isLoadingPjcodes ? (
68
+ <div className="console-timer-settings-loading">
69
+ Loading projects...
70
+ </div>
71
+ ) : (
72
+ <ul className="console-timer-settings-project-list">
73
+ {pjcodes.map((pjcode) => (
74
+ <li
75
+ key={pjcode}
76
+ className="console-timer-settings-project-row"
77
+ >
78
+ <label
79
+ htmlFor={`timer-minutes-${pjcode}`}
80
+ className="console-timer-settings-pjcode"
81
+ >
82
+ {pjcode}
83
+ </label>
84
+ <input
85
+ id={`timer-minutes-${pjcode}`}
86
+ type="number"
87
+ min={0}
88
+ value={projectMinutes[pjcode] ?? 0}
89
+ onChange={(e) =>
90
+ onChangeMinutes(
91
+ pjcode,
92
+ Math.max(0, parseInt(e.target.value, 10) || 0),
93
+ )
94
+ }
95
+ className="console-timer-settings-minutes-input"
96
+ />
97
+ <span className="console-timer-settings-minutes-label">
98
+ {(projectMinutes[pjcode] ?? 0) === 0 ? 'Skip' : 'min'}
99
+ </span>
100
+ </li>
101
+ ))}
102
+ </ul>
103
+ )}
104
+ <div className="console-timer-settings-actions">
105
+ <button
106
+ type="button"
107
+ className="console-timer-settings-save"
108
+ onClick={onSave}
109
+ >
110
+ Save and Close
111
+ </button>
112
+ </div>
113
+ </div>
114
+ </div>
115
+ )}
116
+ </>
117
+ );
118
+ };
@@ -0,0 +1,74 @@
1
+ import { act, renderHook, waitFor } from '@testing-library/react';
2
+ import { useConsoleProjectList } from './useConsoleProjectList';
3
+
4
+ describe('useConsoleProjectList', () => {
5
+ afterEach(() => {
6
+ jest.restoreAllMocks();
7
+ });
8
+
9
+ it('starts in loading state with empty pjcodes and no error', () => {
10
+ global.fetch = jest.fn(
11
+ () => new Promise(() => undefined),
12
+ ) as unknown as typeof fetch;
13
+
14
+ const { result } = renderHook(() => useConsoleProjectList());
15
+ expect(result.current.isLoading).toBe(true);
16
+ expect(result.current.pjcodes).toEqual([]);
17
+ expect(result.current.error).toBeNull();
18
+ });
19
+
20
+ it('populates pjcodes and clears loading state when fetchProjectList resolves', async () => {
21
+ global.fetch = jest.fn(async () => ({
22
+ ok: true,
23
+ status: 200,
24
+ json: async () => ({ pjcodes: ['acme', 'beta'] }),
25
+ })) as unknown as typeof fetch;
26
+
27
+ const { result } = renderHook(() => useConsoleProjectList());
28
+ await waitFor(() => {
29
+ expect(result.current.isLoading).toBe(false);
30
+ });
31
+ expect(result.current.pjcodes).toEqual(['acme', 'beta']);
32
+ expect(result.current.error).toBeNull();
33
+ });
34
+
35
+ it('sets error and clears loading state when fetchProjectList rejects', async () => {
36
+ global.fetch = jest.fn(async () => ({
37
+ ok: false,
38
+ status: 401,
39
+ json: async () => ({}),
40
+ })) as unknown as typeof fetch;
41
+
42
+ const { result } = renderHook(() => useConsoleProjectList());
43
+ await waitFor(() => {
44
+ expect(result.current.isLoading).toBe(false);
45
+ });
46
+ expect(result.current.error).toBeInstanceOf(Error);
47
+ expect(result.current.error?.message).toBe('HTTP 401');
48
+ expect(result.current.pjcodes).toEqual([]);
49
+ });
50
+
51
+ it('skips state update when component unmounts before fetch resolves', async () => {
52
+ let resolveResponse!: (value: unknown) => void;
53
+ const pendingFetch = new Promise<unknown>((resolve) => {
54
+ resolveResponse = resolve;
55
+ });
56
+ global.fetch = jest.fn(() => pendingFetch) as unknown as typeof fetch;
57
+
58
+ const { result, unmount } = renderHook(() => useConsoleProjectList());
59
+ expect(result.current.isLoading).toBe(true);
60
+
61
+ unmount();
62
+
63
+ await act(async () => {
64
+ resolveResponse({
65
+ ok: true,
66
+ status: 200,
67
+ json: async () => ({ pjcodes: ['acme'] }),
68
+ });
69
+ });
70
+
71
+ expect(result.current.pjcodes).toEqual([]);
72
+ expect(result.current.isLoading).toBe(true);
73
+ });
74
+ });
@@ -0,0 +1,40 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { fetchProjectList } from '../lib/consoleApi';
3
+
4
+ export type ConsoleProjectListState = {
5
+ pjcodes: string[];
6
+ isLoading: boolean;
7
+ error: Error | null;
8
+ };
9
+
10
+ export const useConsoleProjectList = (): ConsoleProjectListState => {
11
+ const [pjcodes, setPjcodes] = useState<string[]>([]);
12
+ const [isLoading, setIsLoading] = useState(true);
13
+ const [error, setError] = useState<Error | null>(null);
14
+
15
+ useEffect(() => {
16
+ let cancelled = false;
17
+ setIsLoading(true);
18
+ setError(null);
19
+
20
+ fetchProjectList()
21
+ .then((list) => {
22
+ if (!cancelled) {
23
+ setPjcodes(list);
24
+ setIsLoading(false);
25
+ }
26
+ })
27
+ .catch((cause: unknown) => {
28
+ if (!cancelled) {
29
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
30
+ setIsLoading(false);
31
+ }
32
+ });
33
+
34
+ return () => {
35
+ cancelled = true;
36
+ };
37
+ }, []);
38
+
39
+ return { pjcodes, isLoading, error };
40
+ };
@@ -0,0 +1,59 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { useConsoleProjectTimer } from './useConsoleProjectTimer';
3
+
4
+ describe('useConsoleProjectTimer', () => {
5
+ beforeEach(() => {
6
+ jest.spyOn(Date, 'now');
7
+ });
8
+
9
+ afterEach(() => {
10
+ jest.restoreAllMocks();
11
+ });
12
+
13
+ it('returns false before the configured minutes have elapsed', () => {
14
+ (Date.now as jest.Mock).mockReturnValue(1000);
15
+ const { result } = renderHook(() => useConsoleProjectTimer('alpha'));
16
+ (Date.now as jest.Mock).mockReturnValue(1000 + 4 * 60 * 1000);
17
+ expect(result.current.isTimerExpired(5)).toBe(false);
18
+ });
19
+
20
+ it('returns true once the configured minutes have elapsed', () => {
21
+ (Date.now as jest.Mock).mockReturnValue(0);
22
+ const { result } = renderHook(() => useConsoleProjectTimer('alpha'));
23
+ (Date.now as jest.Mock).mockReturnValue(5 * 60 * 1000);
24
+ expect(result.current.isTimerExpired(5)).toBe(true);
25
+ });
26
+
27
+ it('returns true after more than the configured minutes have elapsed', () => {
28
+ (Date.now as jest.Mock).mockReturnValue(0);
29
+ const { result } = renderHook(() => useConsoleProjectTimer('alpha'));
30
+ (Date.now as jest.Mock).mockReturnValue(10 * 60 * 1000);
31
+ expect(result.current.isTimerExpired(5)).toBe(true);
32
+ });
33
+
34
+ it('returns false when minutes is 0 regardless of elapsed time', () => {
35
+ (Date.now as jest.Mock).mockReturnValue(0);
36
+ const { result } = renderHook(() => useConsoleProjectTimer('alpha'));
37
+ (Date.now as jest.Mock).mockReturnValue(10 * 60 * 1000);
38
+ expect(result.current.isTimerExpired(0)).toBe(false);
39
+ });
40
+
41
+ it('returns false when minutes is negative', () => {
42
+ (Date.now as jest.Mock).mockReturnValue(0);
43
+ const { result } = renderHook(() => useConsoleProjectTimer('alpha'));
44
+ (Date.now as jest.Mock).mockReturnValue(10 * 60 * 1000);
45
+ expect(result.current.isTimerExpired(-1)).toBe(false);
46
+ });
47
+
48
+ it('resets the start time when pjcode changes', () => {
49
+ (Date.now as jest.Mock).mockReturnValue(0);
50
+ const { result, rerender } = renderHook(
51
+ ({ pjcode }: { pjcode: string | null }) => useConsoleProjectTimer(pjcode),
52
+ { initialProps: { pjcode: 'alpha' } },
53
+ );
54
+ (Date.now as jest.Mock).mockReturnValue(4 * 60 * 1000);
55
+ rerender({ pjcode: 'beta' });
56
+ (Date.now as jest.Mock).mockReturnValue(4 * 60 * 1000 + 30 * 1000);
57
+ expect(result.current.isTimerExpired(5)).toBe(false);
58
+ });
59
+ });
@@ -0,0 +1,26 @@
1
+ import { useCallback, useRef } from 'react';
2
+
3
+ export type ConsoleProjectTimerState = {
4
+ isTimerExpired: (minutes: number) => boolean;
5
+ };
6
+
7
+ export const useConsoleProjectTimer = (
8
+ pjcode: string | null,
9
+ ): ConsoleProjectTimerState => {
10
+ const pjcodeRef = useRef(pjcode);
11
+ const startRef = useRef<number>(Date.now());
12
+
13
+ if (pjcodeRef.current !== pjcode) {
14
+ pjcodeRef.current = pjcode;
15
+ startRef.current = Date.now();
16
+ }
17
+
18
+ const isTimerExpired = useCallback((minutes: number): boolean => {
19
+ if (minutes <= 0) {
20
+ return false;
21
+ }
22
+ return Date.now() - startRef.current >= minutes * 60 * 1000;
23
+ }, []);
24
+
25
+ return { isTimerExpired };
26
+ };
@@ -0,0 +1,110 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { TIMER_SETTINGS_KEY } from '../logic/timerSettings';
3
+ import { useConsoleTimerSettings } from './useConsoleTimerSettings';
4
+
5
+ describe('useConsoleTimerSettings', () => {
6
+ beforeEach(() => {
7
+ localStorage.clear();
8
+ });
9
+
10
+ it('initialises with timer mode off and empty project minutes when localStorage is empty', () => {
11
+ const { result } = renderHook(() => useConsoleTimerSettings());
12
+ expect(result.current.timerMode).toBe(false);
13
+ expect(result.current.projectMinutes).toEqual({});
14
+ expect(result.current.isOpen).toBe(false);
15
+ });
16
+
17
+ it('openSettings resets draft to the current saved settings, not the previous draft', () => {
18
+ localStorage.setItem(
19
+ TIMER_SETTINGS_KEY,
20
+ JSON.stringify({ timerMode: true, projectMinutes: { acme: 30 } }),
21
+ );
22
+ const { result } = renderHook(() => useConsoleTimerSettings());
23
+ act(() => {
24
+ result.current.openSettings();
25
+ });
26
+ act(() => {
27
+ result.current.changeDraftMinutes('acme', 99);
28
+ });
29
+ act(() => {
30
+ result.current.closeSettings();
31
+ });
32
+ act(() => {
33
+ result.current.openSettings();
34
+ });
35
+ expect(result.current.draftTimerMode).toBe(true);
36
+ expect(result.current.draftProjectMinutes).toEqual({ acme: 30 });
37
+ });
38
+
39
+ it('openSettings sets isOpen to true', () => {
40
+ const { result } = renderHook(() => useConsoleTimerSettings());
41
+ act(() => {
42
+ result.current.openSettings();
43
+ });
44
+ expect(result.current.isOpen).toBe(true);
45
+ });
46
+
47
+ it('changeDraftMinutes mutates draft but does not change saved or localStorage', () => {
48
+ const { result } = renderHook(() => useConsoleTimerSettings());
49
+ act(() => {
50
+ result.current.openSettings();
51
+ });
52
+ act(() => {
53
+ result.current.changeDraftMinutes('acme', 45);
54
+ });
55
+ expect(result.current.draftProjectMinutes).toEqual({ acme: 45 });
56
+ expect(result.current.projectMinutes).toEqual({});
57
+ expect(localStorage.getItem(TIMER_SETTINGS_KEY)).toBeNull();
58
+ });
59
+
60
+ it('saveSettings writes to localStorage with key tdpm-timer-settings and closes the dialog', () => {
61
+ const { result } = renderHook(() => useConsoleTimerSettings());
62
+ act(() => {
63
+ result.current.openSettings();
64
+ });
65
+ act(() => {
66
+ result.current.toggleDraftTimerMode(true);
67
+ });
68
+ act(() => {
69
+ result.current.changeDraftMinutes('acme', 20);
70
+ });
71
+ act(() => {
72
+ result.current.saveSettings();
73
+ });
74
+ expect(result.current.isOpen).toBe(false);
75
+ const stored = localStorage.getItem(TIMER_SETTINGS_KEY);
76
+ expect(stored).not.toBeNull();
77
+ const parsed = JSON.parse(stored as string) as unknown;
78
+ expect(parsed).toEqual({ timerMode: true, projectMinutes: { acme: 20 } });
79
+ expect(result.current.timerMode).toBe(true);
80
+ expect(result.current.projectMinutes).toEqual({ acme: 20 });
81
+ });
82
+
83
+ it('closeSettings discards draft without writing to localStorage', () => {
84
+ const { result } = renderHook(() => useConsoleTimerSettings());
85
+ act(() => {
86
+ result.current.openSettings();
87
+ });
88
+ act(() => {
89
+ result.current.changeDraftMinutes('acme', 60);
90
+ });
91
+ act(() => {
92
+ result.current.closeSettings();
93
+ });
94
+ expect(result.current.isOpen).toBe(false);
95
+ expect(localStorage.getItem(TIMER_SETTINGS_KEY)).toBeNull();
96
+ expect(result.current.projectMinutes).toEqual({});
97
+ });
98
+
99
+ it('toggleDraftTimerMode updates draftTimerMode without affecting saved timerMode', () => {
100
+ const { result } = renderHook(() => useConsoleTimerSettings());
101
+ act(() => {
102
+ result.current.openSettings();
103
+ });
104
+ act(() => {
105
+ result.current.toggleDraftTimerMode(true);
106
+ });
107
+ expect(result.current.draftTimerMode).toBe(true);
108
+ expect(result.current.timerMode).toBe(false);
109
+ });
110
+ });
@@ -0,0 +1,70 @@
1
+ import { useCallback, useState } from 'react';
2
+ import {
3
+ readTimerSettings,
4
+ type TimerSettings,
5
+ writeTimerSettings,
6
+ } from '../logic/timerSettings';
7
+
8
+ export type ConsoleTimerSettingsState = {
9
+ timerMode: boolean;
10
+ projectMinutes: Record<string, number>;
11
+ isOpen: boolean;
12
+ draftTimerMode: boolean;
13
+ draftProjectMinutes: Record<string, number>;
14
+ openSettings: () => void;
15
+ closeSettings: () => void;
16
+ saveSettings: () => void;
17
+ toggleDraftTimerMode: (enabled: boolean) => void;
18
+ changeDraftMinutes: (pjcode: string, minutes: number) => void;
19
+ };
20
+
21
+ export const useConsoleTimerSettings = (): ConsoleTimerSettingsState => {
22
+ const [saved, setSaved] = useState<TimerSettings>(readTimerSettings);
23
+ const [isOpen, setIsOpen] = useState(false);
24
+ const [draft, setDraft] = useState<TimerSettings>(saved);
25
+
26
+ const openSettings = useCallback(() => {
27
+ setSaved((currentSaved) => {
28
+ setDraft(currentSaved);
29
+ return currentSaved;
30
+ });
31
+ setIsOpen(true);
32
+ }, []);
33
+
34
+ const closeSettings = useCallback(() => {
35
+ setIsOpen(false);
36
+ }, []);
37
+
38
+ const saveSettings = useCallback(() => {
39
+ setDraft((currentDraft) => {
40
+ writeTimerSettings(currentDraft);
41
+ setSaved(currentDraft);
42
+ return currentDraft;
43
+ });
44
+ setIsOpen(false);
45
+ }, []);
46
+
47
+ const toggleDraftTimerMode = useCallback((enabled: boolean) => {
48
+ setDraft((prev) => ({ ...prev, timerMode: enabled }));
49
+ }, []);
50
+
51
+ const changeDraftMinutes = useCallback((pjcode: string, minutes: number) => {
52
+ setDraft((prev) => ({
53
+ ...prev,
54
+ projectMinutes: { ...prev.projectMinutes, [pjcode]: minutes },
55
+ }));
56
+ }, []);
57
+
58
+ return {
59
+ timerMode: saved.timerMode,
60
+ projectMinutes: saved.projectMinutes,
61
+ isOpen,
62
+ draftTimerMode: draft.timerMode,
63
+ draftProjectMinutes: draft.projectMinutes,
64
+ openSettings,
65
+ closeSettings,
66
+ saveSettings,
67
+ toggleDraftTimerMode,
68
+ changeDraftMinutes,
69
+ };
70
+ };
@@ -438,3 +438,25 @@ export const postConsoleReorderStory = async (
438
438
  throw new Error(await readOperationErrorReason(response));
439
439
  }
440
440
  };
441
+
442
+ export const fetchProjectList = async (): Promise<string[]> => {
443
+ const response = await fetch('/api/projects');
444
+ if (!response.ok) {
445
+ throw new Error(`HTTP ${response.status}`);
446
+ }
447
+ const payload: unknown = await response.json();
448
+ if (
449
+ payload === null ||
450
+ typeof payload !== 'object' ||
451
+ Array.isArray(payload)
452
+ ) {
453
+ return [];
454
+ }
455
+ const record = payload as Record<string, unknown>;
456
+ if (!Array.isArray(record.pjcodes)) {
457
+ return [];
458
+ }
459
+ return record.pjcodes.filter(
460
+ (entry): entry is string => typeof entry === 'string',
461
+ );
462
+ };
@@ -0,0 +1,7 @@
1
+ export const navigateReplace = (url: string): void => {
2
+ window.location.replace(url);
3
+ };
4
+
5
+ export const navigateAssign = (url: string): void => {
6
+ window.location.assign(url);
7
+ };
@@ -0,0 +1,61 @@
1
+ import { findNextPjcodeWithMinutes } from './timerSettings';
2
+
3
+ describe('findNextPjcodeWithMinutes', () => {
4
+ const pjcodes = ['alpha', 'beta', 'gamma'];
5
+
6
+ it('returns the next pjcode after the current one with minutes > 0', () => {
7
+ const minutes = { alpha: 5, beta: 5, gamma: 5 };
8
+ expect(findNextPjcodeWithMinutes(pjcodes, 'alpha', minutes)).toBe('beta');
9
+ expect(findNextPjcodeWithMinutes(pjcodes, 'beta', minutes)).toBe('gamma');
10
+ });
11
+
12
+ it('wraps around to the first pjcode after the last one', () => {
13
+ const minutes = { alpha: 5, beta: 5, gamma: 5 };
14
+ expect(findNextPjcodeWithMinutes(pjcodes, 'gamma', minutes)).toBe('alpha');
15
+ });
16
+
17
+ it('returns null when all projects have zero minutes', () => {
18
+ const minutes = { alpha: 0, beta: 0, gamma: 0 };
19
+ expect(findNextPjcodeWithMinutes(pjcodes, 'alpha', minutes)).toBeNull();
20
+ expect(findNextPjcodeWithMinutes(pjcodes, null, minutes)).toBeNull();
21
+ });
22
+
23
+ it('skips projects with zero minutes', () => {
24
+ const minutes = { alpha: 5, beta: 0, gamma: 5 };
25
+ expect(findNextPjcodeWithMinutes(pjcodes, 'alpha', minutes)).toBe('gamma');
26
+ });
27
+
28
+ it('skips consecutive zero-minute projects and wraps around', () => {
29
+ const minutes = { alpha: 5, beta: 0, gamma: 0 };
30
+ expect(findNextPjcodeWithMinutes(pjcodes, 'alpha', minutes)).toBeNull();
31
+
32
+ const minutes2 = { alpha: 0, beta: 5, gamma: 0 };
33
+ expect(findNextPjcodeWithMinutes(pjcodes, 'gamma', minutes2)).toBe('beta');
34
+ });
35
+
36
+ it('returns the first pjcode with minutes > 0 when currentPjcode is null', () => {
37
+ const minutes = { alpha: 0, beta: 5, gamma: 3 };
38
+ expect(findNextPjcodeWithMinutes(pjcodes, null, minutes)).toBe('beta');
39
+ });
40
+
41
+ it('returns the first pjcode when currentPjcode is null and first has minutes > 0', () => {
42
+ const minutes = { alpha: 10, beta: 5, gamma: 3 };
43
+ expect(findNextPjcodeWithMinutes(pjcodes, null, minutes)).toBe('alpha');
44
+ });
45
+
46
+ it('treats missing projectMinutes entry as zero', () => {
47
+ expect(findNextPjcodeWithMinutes(pjcodes, 'alpha', {})).toBeNull();
48
+ });
49
+
50
+ it('returns null for an empty pjcodes list', () => {
51
+ expect(findNextPjcodeWithMinutes([], null, {})).toBeNull();
52
+ expect(findNextPjcodeWithMinutes([], 'alpha', {})).toBeNull();
53
+ });
54
+
55
+ it('handles currentPjcode not found in the list like null-origin', () => {
56
+ const minutes = { alpha: 5, beta: 5, gamma: 5 };
57
+ expect(findNextPjcodeWithMinutes(pjcodes, 'unknown', minutes)).toBe(
58
+ 'alpha',
59
+ );
60
+ });
61
+ });
@@ -0,0 +1,77 @@
1
+ export type TimerSettings = {
2
+ timerMode: boolean;
3
+ projectMinutes: Record<string, number>;
4
+ };
5
+
6
+ export const TIMER_SETTINGS_KEY = 'tdpm-timer-settings';
7
+
8
+ const defaultSettings = (): TimerSettings => ({
9
+ timerMode: false,
10
+ projectMinutes: {},
11
+ });
12
+
13
+ export const readTimerSettings = (): TimerSettings => {
14
+ if (typeof localStorage === 'undefined') {
15
+ return defaultSettings();
16
+ }
17
+ const raw = localStorage.getItem(TIMER_SETTINGS_KEY);
18
+ if (raw === null) {
19
+ return defaultSettings();
20
+ }
21
+ try {
22
+ const parsed: unknown = JSON.parse(raw);
23
+ if (
24
+ parsed === null ||
25
+ typeof parsed !== 'object' ||
26
+ Array.isArray(parsed)
27
+ ) {
28
+ return defaultSettings();
29
+ }
30
+ const record = parsed as Record<string, unknown>;
31
+ const timerMode =
32
+ typeof record.timerMode === 'boolean' ? record.timerMode : false;
33
+ const projectMinutesRaw = record.projectMinutes;
34
+ const projectMinutes: Record<string, number> =
35
+ projectMinutesRaw !== null &&
36
+ typeof projectMinutesRaw === 'object' &&
37
+ !Array.isArray(projectMinutesRaw)
38
+ ? Object.fromEntries(
39
+ Object.entries(projectMinutesRaw as Record<string, unknown>)
40
+ .filter(([, v]) => typeof v === 'number')
41
+ .map(([k, v]) => [k, v as number]),
42
+ )
43
+ : {};
44
+ return { timerMode, projectMinutes };
45
+ } catch {
46
+ return defaultSettings();
47
+ }
48
+ };
49
+
50
+ export const writeTimerSettings = (settings: TimerSettings): void => {
51
+ if (typeof localStorage !== 'undefined') {
52
+ localStorage.setItem(TIMER_SETTINGS_KEY, JSON.stringify(settings));
53
+ }
54
+ };
55
+
56
+ export const findNextPjcodeWithMinutes = (
57
+ pjcodes: string[],
58
+ currentPjcode: string | null,
59
+ projectMinutes: Record<string, number>,
60
+ ): string | null => {
61
+ if (pjcodes.length === 0) {
62
+ return null;
63
+ }
64
+ const currentIndex =
65
+ currentPjcode !== null ? pjcodes.indexOf(currentPjcode) : -1;
66
+ const startIndex =
67
+ currentIndex === -1 ? 0 : (currentIndex + 1) % pjcodes.length;
68
+ const count = currentIndex === -1 ? pjcodes.length : pjcodes.length - 1;
69
+ for (let i = 0; i < count; i++) {
70
+ const index = (startIndex + i) % pjcodes.length;
71
+ const pjcode = pjcodes[index];
72
+ if ((projectMinutes[pjcode] ?? 0) > 0) {
73
+ return pjcode;
74
+ }
75
+ }
76
+ return null;
77
+ };