From 8b0b27ac0866bf8c4405bf7f01c50a0116f6ceb9 Mon Sep 17 00:00:00 2001 From: Whiteknight07 Date: Sun, 19 Apr 2026 02:58:03 +0000 Subject: [PATCH] Add weekly menubar metric preference --- src/App.test.tsx | 8 ++ src/App.tsx | 9 ++ src/components/app/app-content.test.tsx | 7 ++ src/components/app/app-content.tsx | 6 ++ src/hooks/app/use-settings-bootstrap.test.ts | 26 +++++ src/hooks/app/use-settings-bootstrap.ts | 13 +++ .../app/use-settings-display-actions.test.ts | 19 ++++ src/hooks/app/use-settings-display-actions.ts | 12 +++ src/hooks/app/use-tray-icon.ts | 10 ++ src/lib/settings.test.ts | 19 ++++ src/lib/settings.ts | 13 +++ src/lib/tray-primary-progress.test.ts | 99 ++++++++++++++++++- src/lib/tray-primary-progress.ts | 21 +++- src/pages/settings.test.tsx | 15 +++ src/pages/settings.tsx | 12 +++ src/stores/app-preferences-store.ts | 5 + 16 files changed, 291 insertions(+), 3 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index 7e918742..2c75142f 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -21,6 +21,8 @@ const state = vi.hoisted(() => ({ saveResetTimerDisplayModeMock: vi.fn(), loadMenubarIconStyleMock: vi.fn(), saveMenubarIconStyleMock: vi.fn(), + loadPreferMenubarWeeklyLimitMock: vi.fn(), + savePreferMenubarWeeklyLimitMock: vi.fn(), migrateLegacyTraySettingsMock: vi.fn(), loadGlobalShortcutMock: vi.fn(), saveGlobalShortcutMock: vi.fn(), @@ -229,6 +231,8 @@ vi.mock("@/lib/settings", async () => { saveResetTimerDisplayMode: state.saveResetTimerDisplayModeMock, loadMenubarIconStyle: state.loadMenubarIconStyleMock, saveMenubarIconStyle: state.saveMenubarIconStyleMock, + loadPreferMenubarWeeklyLimit: state.loadPreferMenubarWeeklyLimitMock, + savePreferMenubarWeeklyLimit: state.savePreferMenubarWeeklyLimitMock, migrateLegacyTraySettings: state.migrateLegacyTraySettingsMock, loadGlobalShortcut: state.loadGlobalShortcutMock, saveGlobalShortcut: state.saveGlobalShortcutMock, @@ -267,6 +271,8 @@ describe("App", () => { state.saveResetTimerDisplayModeMock.mockReset() state.loadMenubarIconStyleMock.mockReset() state.saveMenubarIconStyleMock.mockReset() + state.loadPreferMenubarWeeklyLimitMock.mockReset() + state.savePreferMenubarWeeklyLimitMock.mockReset() state.migrateLegacyTraySettingsMock.mockReset() state.loadGlobalShortcutMock.mockReset() state.saveGlobalShortcutMock.mockReset() @@ -305,6 +311,8 @@ describe("App", () => { state.saveResetTimerDisplayModeMock.mockResolvedValue(undefined) state.loadMenubarIconStyleMock.mockResolvedValue("provider") state.saveMenubarIconStyleMock.mockResolvedValue(undefined) + state.loadPreferMenubarWeeklyLimitMock.mockResolvedValue(false) + state.savePreferMenubarWeeklyLimitMock.mockResolvedValue(undefined) state.migrateLegacyTraySettingsMock.mockResolvedValue(undefined) state.loadGlobalShortcutMock.mockResolvedValue(null) state.saveGlobalShortcutMock.mockResolvedValue(undefined) diff --git a/src/App.tsx b/src/App.tsx index 94376c85..5d67f863 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,6 +53,8 @@ function App() { setDisplayMode, menubarIconStyle, setMenubarIconStyle, + preferMenubarWeeklyLimit, + setPreferMenubarWeeklyLimit, resetTimerDisplayMode, setResetTimerDisplayMode, setGlobalShortcut, @@ -67,6 +69,8 @@ function App() { setDisplayMode: state.setDisplayMode, menubarIconStyle: state.menubarIconStyle, setMenubarIconStyle: state.setMenubarIconStyle, + preferMenubarWeeklyLimit: state.preferMenubarWeeklyLimit, + setPreferMenubarWeeklyLimit: state.setPreferMenubarWeeklyLimit, resetTimerDisplayMode: state.resetTimerDisplayMode, setResetTimerDisplayMode: state.setResetTimerDisplayMode, setGlobalShortcut: state.setGlobalShortcut, @@ -100,6 +104,7 @@ function App() { pluginStates, displayMode, menubarIconStyle, + preferMenubarWeeklyLimit, activeView, }) @@ -116,6 +121,7 @@ function App() { setThemeMode, setDisplayMode, setMenubarIconStyle, + setPreferMenubarWeeklyLimit, setResetTimerDisplayMode, setGlobalShortcut, setStartOnLogin, @@ -132,12 +138,14 @@ function App() { handleResetTimerDisplayModeChange, handleResetTimerDisplayModeToggle, handleMenubarIconStyleChange, + handlePreferMenubarWeeklyLimitChange, } = useSettingsDisplayActions({ setThemeMode, setDisplayMode, resetTimerDisplayMode, setResetTimerDisplayMode, setMenubarIconStyle, + setPreferMenubarWeeklyLimit, scheduleTrayIconUpdate, }) @@ -245,6 +253,7 @@ function App() { onResetTimerDisplayModeChange: handleResetTimerDisplayModeChange, onResetTimerDisplayModeToggle: handleResetTimerDisplayModeToggle, onMenubarIconStyleChange: handleMenubarIconStyleChange, + onPreferMenubarWeeklyLimitChange: handlePreferMenubarWeeklyLimitChange, traySettingsPreview, onGlobalShortcutChange: handleGlobalShortcutChange, onStartOnLoginChange: handleStartOnLoginChange, diff --git a/src/components/app/app-content.test.tsx b/src/components/app/app-content.test.tsx index 0cce5238..9b1017ea 100644 --- a/src/components/app/app-content.test.tsx +++ b/src/components/app/app-content.test.tsx @@ -63,6 +63,13 @@ function createProps(): AppContentProps { onDisplayModeChange: vi.fn(), onResetTimerDisplayModeChange: vi.fn(), onResetTimerDisplayModeToggle: vi.fn(), + onMenubarIconStyleChange: vi.fn(), + onPreferMenubarWeeklyLimitChange: vi.fn(), + traySettingsPreview: { + bars: [], + providerBars: [], + providerPercentText: "--%", + }, onGlobalShortcutChange: vi.fn(), onStartOnLoginChange: vi.fn(), } diff --git a/src/components/app/app-content.tsx b/src/components/app/app-content.tsx index e362fa76..45d4e277 100644 --- a/src/components/app/app-content.tsx +++ b/src/components/app/app-content.tsx @@ -32,6 +32,7 @@ export type AppContentActionProps = { onResetTimerDisplayModeChange: (mode: ResetTimerDisplayMode) => void onResetTimerDisplayModeToggle: () => void onMenubarIconStyleChange: (value: MenubarIconStyle) => void + onPreferMenubarWeeklyLimitChange: (value: boolean) => void traySettingsPreview: TraySettingsPreview onGlobalShortcutChange: (value: GlobalShortcut) => void onStartOnLoginChange: (value: boolean) => void @@ -52,6 +53,7 @@ export function AppContent({ onResetTimerDisplayModeChange, onResetTimerDisplayModeToggle, onMenubarIconStyleChange, + onPreferMenubarWeeklyLimitChange, traySettingsPreview, onGlobalShortcutChange, onStartOnLoginChange, @@ -66,6 +68,7 @@ export function AppContent({ displayMode, resetTimerDisplayMode, menubarIconStyle, + preferMenubarWeeklyLimit, autoUpdateInterval, globalShortcut, themeMode, @@ -75,6 +78,7 @@ export function AppContent({ displayMode: state.displayMode, resetTimerDisplayMode: state.resetTimerDisplayMode, menubarIconStyle: state.menubarIconStyle, + preferMenubarWeeklyLimit: state.preferMenubarWeeklyLimit, autoUpdateInterval: state.autoUpdateInterval, globalShortcut: state.globalShortcut, themeMode: state.themeMode, @@ -110,6 +114,8 @@ export function AppContent({ onResetTimerDisplayModeChange={onResetTimerDisplayModeChange} menubarIconStyle={menubarIconStyle} onMenubarIconStyleChange={onMenubarIconStyleChange} + preferMenubarWeeklyLimit={preferMenubarWeeklyLimit} + onPreferMenubarWeeklyLimitChange={onPreferMenubarWeeklyLimitChange} traySettingsPreview={traySettingsPreview} globalShortcut={globalShortcut} onGlobalShortcutChange={onGlobalShortcutChange} diff --git a/src/hooks/app/use-settings-bootstrap.test.ts b/src/hooks/app/use-settings-bootstrap.test.ts index 9eae9816..42cf36ca 100644 --- a/src/hooks/app/use-settings-bootstrap.test.ts +++ b/src/hooks/app/use-settings-bootstrap.test.ts @@ -13,6 +13,7 @@ const { loadDisplayModeMock, loadGlobalShortcutMock, loadMenubarIconStyleMock, + loadPreferMenubarWeeklyLimitMock, loadPluginSettingsMock, loadResetTimerDisplayModeMock, loadStartOnLoginMock, @@ -32,6 +33,7 @@ const { loadDisplayModeMock: vi.fn(), loadGlobalShortcutMock: vi.fn(), loadMenubarIconStyleMock: vi.fn(), + loadPreferMenubarWeeklyLimitMock: vi.fn(), loadPluginSettingsMock: vi.fn(), loadResetTimerDisplayModeMock: vi.fn(), loadStartOnLoginMock: vi.fn(), @@ -58,6 +60,7 @@ vi.mock("@/lib/settings", () => ({ DEFAULT_DISPLAY_MODE: "left", DEFAULT_GLOBAL_SHORTCUT: null, DEFAULT_MENUBAR_ICON_STYLE: "provider", + DEFAULT_PREFER_MENUBAR_WEEKLY_LIMIT: false, DEFAULT_RESET_TIMER_DISPLAY_MODE: "relative", DEFAULT_START_ON_LOGIN: false, DEFAULT_THEME_MODE: "system", @@ -66,6 +69,7 @@ vi.mock("@/lib/settings", () => ({ loadDisplayMode: loadDisplayModeMock, loadGlobalShortcut: loadGlobalShortcutMock, loadMenubarIconStyle: loadMenubarIconStyleMock, + loadPreferMenubarWeeklyLimit: loadPreferMenubarWeeklyLimitMock, loadPluginSettings: loadPluginSettingsMock, loadResetTimerDisplayMode: loadResetTimerDisplayModeMock, loadStartOnLogin: loadStartOnLoginMock, @@ -88,6 +92,7 @@ function createArgs() { setGlobalShortcut: vi.fn(), setStartOnLogin: vi.fn(), setMenubarIconStyle: vi.fn(), + setPreferMenubarWeeklyLimit: vi.fn(), setLoadingForPlugins: vi.fn(), setErrorForPlugins: vi.fn(), startBatch: vi.fn().mockResolvedValue(undefined), @@ -107,6 +112,7 @@ describe("useSettingsBootstrap", () => { loadDisplayModeMock.mockReset() loadGlobalShortcutMock.mockReset() loadMenubarIconStyleMock.mockReset() + loadPreferMenubarWeeklyLimitMock.mockReset() loadPluginSettingsMock.mockReset() loadResetTimerDisplayModeMock.mockReset() loadStartOnLoginMock.mockReset() @@ -136,6 +142,7 @@ describe("useSettingsBootstrap", () => { loadResetTimerDisplayModeMock.mockResolvedValue("relative") loadGlobalShortcutMock.mockResolvedValue("CommandOrControl+Shift+O") loadMenubarIconStyleMock.mockResolvedValue("provider") + loadPreferMenubarWeeklyLimitMock.mockResolvedValue(true) loadStartOnLoginMock.mockResolvedValue(true) migrateLegacyTraySettingsMock.mockResolvedValue(undefined) savePluginSettingsMock.mockResolvedValue(undefined) @@ -170,4 +177,23 @@ describe("useSettingsBootstrap", () => { errorSpy.mockRestore() }) + + it("falls back to default menubar weekly limit preference when loading fails", async () => { + const weeklyPreferenceError = new Error("weekly preference unavailable") + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + loadPreferMenubarWeeklyLimitMock.mockRejectedValueOnce(weeklyPreferenceError) + const args = createArgs() + + renderHook(() => useSettingsBootstrap(args)) + + await waitFor(() => { + expect(errorSpy).toHaveBeenCalledWith( + "Failed to load menubar weekly limit preference:", + weeklyPreferenceError + ) + expect(args.setPreferMenubarWeeklyLimit).toHaveBeenCalledWith(false) + }) + + errorSpy.mockRestore() + }) }) diff --git a/src/hooks/app/use-settings-bootstrap.ts b/src/hooks/app/use-settings-bootstrap.ts index fcc7df09..25a4c1e8 100644 --- a/src/hooks/app/use-settings-bootstrap.ts +++ b/src/hooks/app/use-settings-bootstrap.ts @@ -12,6 +12,7 @@ import { DEFAULT_DISPLAY_MODE, DEFAULT_GLOBAL_SHORTCUT, DEFAULT_MENUBAR_ICON_STYLE, + DEFAULT_PREFER_MENUBAR_WEEKLY_LIMIT, DEFAULT_RESET_TIMER_DISPLAY_MODE, DEFAULT_START_ON_LOGIN, DEFAULT_THEME_MODE, @@ -21,6 +22,7 @@ import { loadGlobalShortcut, loadMenubarIconStyle, migrateLegacyTraySettings, + loadPreferMenubarWeeklyLimit, loadPluginSettings, loadResetTimerDisplayMode, loadStartOnLogin, @@ -46,6 +48,7 @@ type UseSettingsBootstrapArgs = { setGlobalShortcut: (value: GlobalShortcut) => void setStartOnLogin: (value: boolean) => void setMenubarIconStyle: (value: MenubarIconStyle) => void + setPreferMenubarWeeklyLimit: (value: boolean) => void setLoadingForPlugins: (ids: string[]) => void setErrorForPlugins: (ids: string[], error: string) => void startBatch: (pluginIds?: string[]) => Promise @@ -61,6 +64,7 @@ export function useSettingsBootstrap({ setGlobalShortcut, setStartOnLogin, setMenubarIconStyle, + setPreferMenubarWeeklyLimit, setLoadingForPlugins, setErrorForPlugins, startBatch, @@ -153,6 +157,13 @@ export function useSettingsBootstrap({ console.error("Failed to load menubar icon style:", error) } + let storedPreferMenubarWeeklyLimit = DEFAULT_PREFER_MENUBAR_WEEKLY_LIMIT + try { + storedPreferMenubarWeeklyLimit = await loadPreferMenubarWeeklyLimit() + } catch (error) { + console.error("Failed to load menubar weekly limit preference:", error) + } + if (isMounted) { setPluginSettings(normalized) setAutoUpdateInterval(storedInterval) @@ -162,6 +173,7 @@ export function useSettingsBootstrap({ setGlobalShortcut(storedGlobalShortcut) setStartOnLogin(storedStartOnLogin) setMenubarIconStyle(storedMenubarIconStyle) + setPreferMenubarWeeklyLimit(storedPreferMenubarWeeklyLimit) const enabledIds = getEnabledPluginIds(normalized) setLoadingForPlugins(enabledIds) @@ -192,6 +204,7 @@ export function useSettingsBootstrap({ setGlobalShortcut, setLoadingForPlugins, setMenubarIconStyle, + setPreferMenubarWeeklyLimit, migrateLegacyTraySettings, setPluginSettings, setPluginsMeta, diff --git a/src/hooks/app/use-settings-display-actions.test.ts b/src/hooks/app/use-settings-display-actions.test.ts index 8de05bb6..7f50973c 100644 --- a/src/hooks/app/use-settings-display-actions.test.ts +++ b/src/hooks/app/use-settings-display-actions.test.ts @@ -3,17 +3,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest" const { saveDisplayModeMock, + savePreferMenubarWeeklyLimitMock, saveResetTimerDisplayModeMock, saveThemeModeMock, } = vi.hoisted(() => ({ saveThemeModeMock: vi.fn(), saveDisplayModeMock: vi.fn(), + savePreferMenubarWeeklyLimitMock: vi.fn(), saveResetTimerDisplayModeMock: vi.fn(), })) vi.mock("@/lib/settings", () => ({ saveThemeMode: saveThemeModeMock, saveDisplayMode: saveDisplayModeMock, + savePreferMenubarWeeklyLimit: savePreferMenubarWeeklyLimitMock, saveResetTimerDisplayMode: saveResetTimerDisplayModeMock, })) @@ -23,9 +26,11 @@ describe("useSettingsDisplayActions", () => { beforeEach(() => { saveThemeModeMock.mockReset() saveDisplayModeMock.mockReset() + savePreferMenubarWeeklyLimitMock.mockReset() saveResetTimerDisplayModeMock.mockReset() saveThemeModeMock.mockResolvedValue(undefined) saveDisplayModeMock.mockResolvedValue(undefined) + savePreferMenubarWeeklyLimitMock.mockResolvedValue(undefined) saveResetTimerDisplayModeMock.mockResolvedValue(undefined) }) @@ -33,6 +38,7 @@ describe("useSettingsDisplayActions", () => { const setThemeMode = vi.fn() const setDisplayMode = vi.fn() const setResetTimerDisplayMode = vi.fn() + const setPreferMenubarWeeklyLimit = vi.fn() const scheduleTrayIconUpdate = vi.fn() const { result } = renderHook(() => @@ -41,6 +47,7 @@ describe("useSettingsDisplayActions", () => { setDisplayMode, resetTimerDisplayMode: "relative", setResetTimerDisplayMode, + setPreferMenubarWeeklyLimit, scheduleTrayIconUpdate, }) ) @@ -49,16 +56,19 @@ describe("useSettingsDisplayActions", () => { result.current.handleThemeModeChange("dark") result.current.handleDisplayModeChange("used") result.current.handleResetTimerDisplayModeChange("absolute") + result.current.handlePreferMenubarWeeklyLimitChange(true) }) expect(setThemeMode).toHaveBeenCalledWith("dark") expect(setDisplayMode).toHaveBeenCalledWith("used") expect(setResetTimerDisplayMode).toHaveBeenCalledWith("absolute") + expect(setPreferMenubarWeeklyLimit).toHaveBeenCalledWith(true) expect(scheduleTrayIconUpdate).toHaveBeenCalledWith("settings", 0) expect(saveThemeModeMock).toHaveBeenCalledWith("dark") expect(saveDisplayModeMock).toHaveBeenCalledWith("used") expect(saveResetTimerDisplayModeMock).toHaveBeenCalledWith("absolute") + expect(savePreferMenubarWeeklyLimitMock).toHaveBeenCalledWith(true) }) it("toggles reset timer mode in both directions", () => { @@ -71,6 +81,7 @@ describe("useSettingsDisplayActions", () => { setDisplayMode: vi.fn(), resetTimerDisplayMode: mode, setResetTimerDisplayMode, + setPreferMenubarWeeklyLimit: vi.fn(), scheduleTrayIconUpdate: vi.fn(), }), { initialProps: { mode: "relative" as const } } @@ -92,10 +103,12 @@ describe("useSettingsDisplayActions", () => { const themeError = new Error("theme failed") const displayError = new Error("display failed") const resetError = new Error("reset failed") + const menubarWeeklyError = new Error("menubar weekly failed") const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) saveThemeModeMock.mockRejectedValueOnce(themeError) saveDisplayModeMock.mockRejectedValueOnce(displayError) saveResetTimerDisplayModeMock.mockRejectedValueOnce(resetError) + savePreferMenubarWeeklyLimitMock.mockRejectedValueOnce(menubarWeeklyError) const { result } = renderHook(() => useSettingsDisplayActions({ @@ -103,6 +116,7 @@ describe("useSettingsDisplayActions", () => { setDisplayMode: vi.fn(), resetTimerDisplayMode: "relative", setResetTimerDisplayMode: vi.fn(), + setPreferMenubarWeeklyLimit: vi.fn(), scheduleTrayIconUpdate: vi.fn(), }) ) @@ -111,12 +125,17 @@ describe("useSettingsDisplayActions", () => { result.current.handleThemeModeChange("light") result.current.handleDisplayModeChange("left") result.current.handleResetTimerDisplayModeChange("relative") + result.current.handlePreferMenubarWeeklyLimitChange(true) }) await waitFor(() => { expect(errorSpy).toHaveBeenCalledWith("Failed to save theme mode:", themeError) expect(errorSpy).toHaveBeenCalledWith("Failed to save display mode:", displayError) expect(errorSpy).toHaveBeenCalledWith("Failed to save reset timer display mode:", resetError) + expect(errorSpy).toHaveBeenCalledWith( + "Failed to save menubar weekly limit preference:", + menubarWeeklyError + ) }) errorSpy.mockRestore() diff --git a/src/hooks/app/use-settings-display-actions.ts b/src/hooks/app/use-settings-display-actions.ts index ac02e047..ede4ac63 100644 --- a/src/hooks/app/use-settings-display-actions.ts +++ b/src/hooks/app/use-settings-display-actions.ts @@ -2,6 +2,7 @@ import { useCallback } from "react" import { saveDisplayMode, saveMenubarIconStyle, + savePreferMenubarWeeklyLimit, saveResetTimerDisplayMode, saveThemeMode, type DisplayMode, @@ -18,6 +19,7 @@ type UseSettingsDisplayActionsArgs = { resetTimerDisplayMode: ResetTimerDisplayMode setResetTimerDisplayMode: (value: ResetTimerDisplayMode) => void setMenubarIconStyle: (value: MenubarIconStyle) => void + setPreferMenubarWeeklyLimit: (value: boolean) => void scheduleTrayIconUpdate: ScheduleTrayIconUpdate } @@ -27,6 +29,7 @@ export function useSettingsDisplayActions({ resetTimerDisplayMode, setResetTimerDisplayMode, setMenubarIconStyle, + setPreferMenubarWeeklyLimit, scheduleTrayIconUpdate, }: UseSettingsDisplayActionsArgs) { const handleThemeModeChange = useCallback((mode: ThemeMode) => { @@ -64,11 +67,20 @@ export function useSettingsDisplayActions({ }) }, [scheduleTrayIconUpdate, setMenubarIconStyle]) + const handlePreferMenubarWeeklyLimitChange = useCallback((value: boolean) => { + setPreferMenubarWeeklyLimit(value) + scheduleTrayIconUpdate("settings", 0) + void savePreferMenubarWeeklyLimit(value).catch((error) => { + console.error("Failed to save menubar weekly limit preference:", error) + }) + }, [scheduleTrayIconUpdate, setPreferMenubarWeeklyLimit]) + return { handleThemeModeChange, handleDisplayModeChange, handleResetTimerDisplayModeChange, handleResetTimerDisplayModeToggle, handleMenubarIconStyleChange, + handlePreferMenubarWeeklyLimitChange, } } diff --git a/src/hooks/app/use-tray-icon.ts b/src/hooks/app/use-tray-icon.ts index a98acc8b..025e809b 100644 --- a/src/hooks/app/use-tray-icon.ts +++ b/src/hooks/app/use-tray-icon.ts @@ -17,6 +17,7 @@ type UseTrayIconArgs = { pluginStates: Record displayMode: DisplayMode menubarIconStyle: MenubarIconStyle + preferMenubarWeeklyLimit: boolean activeView: string } @@ -55,6 +56,7 @@ export function useTrayIcon({ pluginStates, displayMode, menubarIconStyle, + preferMenubarWeeklyLimit, activeView, }: UseTrayIconArgs) { const trayRef = useRef(null) @@ -72,6 +74,7 @@ export function useTrayIcon({ const pluginStatesRef = useRef(pluginStates) const displayModeRef = useRef(displayMode) const menubarIconStyleRef = useRef(menubarIconStyle) + const preferMenubarWeeklyLimitRef = useRef(preferMenubarWeeklyLimit) const activeViewRef = useRef(activeView) const lastTrayProviderIdRef = useRef(null) @@ -95,6 +98,10 @@ export function useTrayIcon({ menubarIconStyleRef.current = menubarIconStyle }, [menubarIconStyle]) + useEffect(() => { + preferMenubarWeeklyLimitRef.current = preferMenubarWeeklyLimit + }, [preferMenubarWeeklyLimit]) + useEffect(() => { activeViewRef.current = activeView }, [activeView]) @@ -208,6 +215,7 @@ export function useTrayIcon({ pluginStates: pluginStatesRef.current, maxBars: 4, displayMode: displayModeRef.current, + preferWeeklyLimit: preferMenubarWeeklyLimitRef.current, }) const providerBars = trayProviderId @@ -218,6 +226,7 @@ export function useTrayIcon({ maxBars: 1, displayMode: displayModeRef.current, pluginId: trayProviderId, + preferWeeklyLimit: preferMenubarWeeklyLimitRef.current, }) : [] @@ -242,6 +251,7 @@ export function useTrayIcon({ pluginStates: pluginStatesRef.current, maxBars: 20, // Show more in tooltip displayMode: displayModeRef.current, + preferWeeklyLimit: preferMenubarWeeklyLimitRef.current, }) const tooltip = formatTrayTooltip(tooltipBars, pluginsMetaRef.current) const updateTooltip = () => setTrayTooltip(tooltip) diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 1c686863..3c8501e1 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -4,6 +4,7 @@ import { DEFAULT_DISPLAY_MODE, DEFAULT_GLOBAL_SHORTCUT, DEFAULT_MENUBAR_ICON_STYLE, + DEFAULT_PREFER_MENUBAR_WEEKLY_LIMIT, DEFAULT_PLUGIN_SETTINGS, DEFAULT_RESET_TIMER_DISPLAY_MODE, DEFAULT_START_ON_LOGIN, @@ -14,6 +15,7 @@ import { loadDisplayMode, loadGlobalShortcut, loadMenubarIconStyle, + loadPreferMenubarWeeklyLimit, loadPluginSettings, loadResetTimerDisplayMode, loadStartOnLogin, @@ -24,6 +26,7 @@ import { saveDisplayMode, saveGlobalShortcut, saveMenubarIconStyle, + savePreferMenubarWeeklyLimit, savePluginSettings, saveResetTimerDisplayMode, saveStartOnLogin, @@ -261,6 +264,22 @@ describe("settings", () => { await expect(loadMenubarIconStyle()).resolves.toBe(DEFAULT_MENUBAR_ICON_STYLE) }) + it("loads default menubar weekly limit preference when missing", async () => { + await expect(loadPreferMenubarWeeklyLimit()).resolves.toBe( + DEFAULT_PREFER_MENUBAR_WEEKLY_LIMIT + ) + }) + + it("loads stored menubar weekly limit preference", async () => { + storeState.set("preferMenubarWeeklyLimit", true) + await expect(loadPreferMenubarWeeklyLimit()).resolves.toBe(true) + }) + + it("saves menubar weekly limit preference", async () => { + await savePreferMenubarWeeklyLimit(true) + await expect(loadPreferMenubarWeeklyLimit()).resolves.toBe(true) + }) + it("skips legacy tray migration when keys are absent", async () => { await expect(migrateLegacyTraySettings()).resolves.toBeUndefined() expect(storeState.has("trayIconStyle")).toBe(false) diff --git a/src/lib/settings.ts b/src/lib/settings.ts index a94d0a7c..9b0034ec 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -29,6 +29,7 @@ const THEME_MODE_KEY = "themeMode"; const DISPLAY_MODE_KEY = "displayMode"; const RESET_TIMER_DISPLAY_MODE_KEY = "resetTimerDisplayMode"; const MENUBAR_ICON_STYLE_KEY = "menubarIconStyle"; +const PREFER_MENUBAR_WEEKLY_LIMIT_KEY = "preferMenubarWeeklyLimit"; const LEGACY_TRAY_ICON_STYLE_KEY = "trayIconStyle"; const LEGACY_TRAY_SHOW_PERCENTAGE_KEY = "trayShowPercentage"; const GLOBAL_SHORTCUT_KEY = "globalShortcut"; @@ -39,6 +40,7 @@ export const DEFAULT_THEME_MODE: ThemeMode = "system"; export const DEFAULT_DISPLAY_MODE: DisplayMode = "left"; export const DEFAULT_RESET_TIMER_DISPLAY_MODE: ResetTimerDisplayMode = "relative"; export const DEFAULT_MENUBAR_ICON_STYLE: MenubarIconStyle = "provider"; +export const DEFAULT_PREFER_MENUBAR_WEEKLY_LIMIT = false; export const DEFAULT_GLOBAL_SHORTCUT: GlobalShortcut = null; export const DEFAULT_START_ON_LOGIN = false; @@ -232,6 +234,17 @@ export async function saveMenubarIconStyle(style: MenubarIconStyle): Promise { + const stored = await store.get(PREFER_MENUBAR_WEEKLY_LIMIT_KEY); + if (typeof stored === "boolean") return stored; + return DEFAULT_PREFER_MENUBAR_WEEKLY_LIMIT; +} + +export async function savePreferMenubarWeeklyLimit(value: boolean): Promise { + await store.set(PREFER_MENUBAR_WEEKLY_LIMIT_KEY, value); + await store.save(); +} + type LegacyStoreWithDelete = { delete?: (key: string) => Promise; }; diff --git a/src/lib/tray-primary-progress.test.ts b/src/lib/tray-primary-progress.test.ts index f2989a5b..2874fa53 100644 --- a/src/lib/tray-primary-progress.test.ts +++ b/src/lib/tray-primary-progress.test.ts @@ -286,6 +286,104 @@ describe("getTrayPrimaryBars", () => { expect(bars).toEqual([{ id: "a", fraction: 0.2 }]) }) + it("prefers weekly overview line when enabled", () => { + const bars = getTrayPrimaryBars({ + displayMode: "used", + preferWeeklyLimit: true, + pluginsMeta: [ + { + id: "a", + name: "A", + iconUrl: "", + primaryCandidates: ["Session"], + lines: [ + { type: "progress", label: "Session", scope: "overview" }, + { type: "progress", label: "Weekly", scope: "overview" }, + ], + }, + ], + pluginSettings: { order: ["a"], disabled: [] }, + pluginStates: { + a: { + data: { + providerId: "a", + displayName: "A", + iconUrl: "", + lines: [ + { + type: "progress", + label: "Session", + used: 20, + limit: 100, + format: { kind: "percent" }, + }, + { + type: "progress", + label: "Weekly", + used: 60, + limit: 100, + format: { kind: "percent" }, + }, + ], + }, + loading: false, + error: null, + }, + }, + }) + + expect(bars).toEqual([{ id: "a", fraction: 0.6 }]) + }) + + it("falls back to primary candidates when no weekly overview line is available", () => { + const bars = getTrayPrimaryBars({ + displayMode: "used", + preferWeeklyLimit: true, + pluginsMeta: [ + { + id: "a", + name: "A", + iconUrl: "", + primaryCandidates: ["Session"], + lines: [ + { type: "progress", label: "Session", scope: "overview" }, + { type: "progress", label: "Monthly", scope: "overview" }, + ], + }, + ], + pluginSettings: { order: ["a"], disabled: [] }, + pluginStates: { + a: { + data: { + providerId: "a", + displayName: "A", + iconUrl: "", + lines: [ + { + type: "progress", + label: "Session", + used: 20, + limit: 100, + format: { kind: "percent" }, + }, + { + type: "progress", + label: "Monthly", + used: 60, + limit: 100, + format: { kind: "percent" }, + }, + ], + }, + loading: false, + error: null, + }, + }, + }) + + expect(bars).toEqual([{ id: "a", fraction: 0.2 }]) + }) + it("skips plugins with empty primaryCandidates", () => { const bars = getTrayPrimaryBars({ pluginsMeta: [ @@ -303,4 +401,3 @@ describe("getTrayPrimaryBars", () => { expect(bars).toEqual([]) }) }) - diff --git a/src/lib/tray-primary-progress.ts b/src/lib/tray-primary-progress.ts index a139e29d..db392357 100644 --- a/src/lib/tray-primary-progress.ts +++ b/src/lib/tray-primary-progress.ts @@ -23,6 +23,15 @@ function isProgressLine(line: PluginOutput["lines"][number]): line is ProgressLi return line.type === "progress" } +function isWeeklyOverviewLine(meta: PluginMeta, label: string): boolean { + return meta.lines.some((line) => + line.type === "progress" && + line.scope === "overview" && + line.label === label && + /weekly/i.test(label) + ) +} + export function getTrayPrimaryBars(args: { pluginsMeta: PluginMeta[] pluginSettings: PluginSettings | null @@ -30,6 +39,7 @@ export function getTrayPrimaryBars(args: { maxBars?: number displayMode?: DisplayMode pluginId?: string + preferWeeklyLimit?: boolean }): TrayPrimaryBar[] { const { pluginsMeta, @@ -38,6 +48,7 @@ export function getTrayPrimaryBars(args: { maxBars = 4, displayMode = DEFAULT_DISPLAY_MODE, pluginId, + preferWeeklyLimit = false, } = args if (!pluginSettings) return [] @@ -61,8 +72,15 @@ export function getTrayPrimaryBars(args: { let fraction: number | undefined if (data) { + const weeklyLabel = preferWeeklyLimit + ? data.lines + .filter(isProgressLine) + .find((line) => isWeeklyOverviewLine(meta, line.label)) + ?.label + : undefined + // Find first candidate that exists in runtime data - const primaryLabel = meta.primaryCandidates.find((label) => + const primaryLabel = weeklyLabel ?? meta.primaryCandidates.find((label) => data.lines.some((line) => isProgressLine(line) && line.label === label) ) if (primaryLabel) { @@ -86,4 +104,3 @@ export function getTrayPrimaryBars(args: { return out } - diff --git a/src/pages/settings.test.tsx b/src/pages/settings.test.tsx index 9139f79e..3568b43f 100644 --- a/src/pages/settings.test.tsx +++ b/src/pages/settings.test.tsx @@ -57,6 +57,8 @@ const defaultProps = { onResetTimerDisplayModeChange: vi.fn(), menubarIconStyle: "provider" as const, onMenubarIconStyleChange: vi.fn(), + preferMenubarWeeklyLimit: false, + onPreferMenubarWeeklyLimitChange: vi.fn(), traySettingsPreview: { bars: [{ id: "a", fraction: 0.7 }], providerBars: [{ id: "a", fraction: 0.7 }], @@ -227,6 +229,19 @@ describe("SettingsPage", () => { expect(screen.queryByText("Show percentage")).not.toBeInTheDocument() }) + it("toggles menubar weekly limit preference", async () => { + const onPreferMenubarWeeklyLimitChange = vi.fn() + render( + + ) + + await userEvent.click(screen.getByText("Prefer weekly limits when available")) + expect(onPreferMenubarWeeklyLimitChange).toHaveBeenCalledWith(true) + }) + it("toggles start on login checkbox", async () => { const onStartOnLoginChange = vi.fn() render( diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index bc257202..89a1bb24 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -270,6 +270,8 @@ interface SettingsPageProps { onResetTimerDisplayModeChange: (value: ResetTimerDisplayMode) => void; menubarIconStyle: MenubarIconStyle; onMenubarIconStyleChange: (value: MenubarIconStyle) => void; + preferMenubarWeeklyLimit: boolean; + onPreferMenubarWeeklyLimitChange: (value: boolean) => void; traySettingsPreview: TraySettingsPreview; globalShortcut: GlobalShortcut; onGlobalShortcutChange: (value: GlobalShortcut) => void; @@ -291,6 +293,8 @@ export function SettingsPage({ onResetTimerDisplayModeChange, menubarIconStyle, onMenubarIconStyleChange, + preferMenubarWeeklyLimit, + onPreferMenubarWeeklyLimitChange, traySettingsPreview, globalShortcut, onGlobalShortcutChange, @@ -443,6 +447,14 @@ export function SettingsPage({ })} +

App Theme

diff --git a/src/stores/app-preferences-store.ts b/src/stores/app-preferences-store.ts index 98ced539..9b9fd4c7 100644 --- a/src/stores/app-preferences-store.ts +++ b/src/stores/app-preferences-store.ts @@ -4,6 +4,7 @@ import { DEFAULT_DISPLAY_MODE, DEFAULT_GLOBAL_SHORTCUT, DEFAULT_MENUBAR_ICON_STYLE, + DEFAULT_PREFER_MENUBAR_WEEKLY_LIMIT, DEFAULT_RESET_TIMER_DISPLAY_MODE, DEFAULT_START_ON_LOGIN, DEFAULT_THEME_MODE, @@ -23,6 +24,7 @@ type AppPreferencesStore = { globalShortcut: GlobalShortcut startOnLogin: boolean menubarIconStyle: MenubarIconStyle + preferMenubarWeeklyLimit: boolean setAutoUpdateInterval: (value: AutoUpdateIntervalMinutes) => void setThemeMode: (value: ThemeMode) => void setDisplayMode: (value: DisplayMode) => void @@ -30,6 +32,7 @@ type AppPreferencesStore = { setGlobalShortcut: (value: GlobalShortcut) => void setStartOnLogin: (value: boolean) => void setMenubarIconStyle: (value: MenubarIconStyle) => void + setPreferMenubarWeeklyLimit: (value: boolean) => void resetState: () => void } @@ -41,6 +44,7 @@ const initialState = { globalShortcut: DEFAULT_GLOBAL_SHORTCUT, startOnLogin: DEFAULT_START_ON_LOGIN, menubarIconStyle: DEFAULT_MENUBAR_ICON_STYLE, + preferMenubarWeeklyLimit: DEFAULT_PREFER_MENUBAR_WEEKLY_LIMIT, } export const useAppPreferencesStore = create((set) => ({ @@ -52,5 +56,6 @@ export const useAppPreferencesStore = create((set) => ({ setGlobalShortcut: (value) => set({ globalShortcut: value }), setStartOnLogin: (value) => set({ startOnLogin: value }), setMenubarIconStyle: (value) => set({ menubarIconStyle: value }), + setPreferMenubarWeeklyLimit: (value) => set({ preferMenubarWeeklyLimit: value }), resetState: () => set(initialState), }))