diff --git a/src/App.test.tsx b/src/App.test.tsx index 9c556942..99f7c07f 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -22,6 +22,8 @@ const state = vi.hoisted(() => ({ saveResetTimerDisplayModeMock: vi.fn(), loadMenubarIconStyleMock: vi.fn(), saveMenubarIconStyleMock: vi.fn(), + loadWeeklyWarningThresholdPercentMock: vi.fn(), + saveWeeklyWarningThresholdPercentMock: vi.fn(), migrateLegacyTraySettingsMock: vi.fn(), loadGlobalShortcutMock: vi.fn(), saveGlobalShortcutMock: vi.fn(), @@ -234,6 +236,8 @@ vi.mock("@/lib/settings", async () => { saveResetTimerDisplayMode: state.saveResetTimerDisplayModeMock, loadMenubarIconStyle: state.loadMenubarIconStyleMock, saveMenubarIconStyle: state.saveMenubarIconStyleMock, + loadWeeklyWarningThresholdPercent: state.loadWeeklyWarningThresholdPercentMock, + saveWeeklyWarningThresholdPercent: state.saveWeeklyWarningThresholdPercentMock, migrateLegacyTraySettings: state.migrateLegacyTraySettingsMock, loadGlobalShortcut: state.loadGlobalShortcutMock, saveGlobalShortcut: state.saveGlobalShortcutMock, @@ -273,6 +277,8 @@ describe("App", () => { state.saveResetTimerDisplayModeMock.mockReset() state.loadMenubarIconStyleMock.mockReset() state.saveMenubarIconStyleMock.mockReset() + state.loadWeeklyWarningThresholdPercentMock.mockReset() + state.saveWeeklyWarningThresholdPercentMock.mockReset() state.migrateLegacyTraySettingsMock.mockReset() state.loadGlobalShortcutMock.mockReset() state.saveGlobalShortcutMock.mockReset() @@ -311,6 +317,8 @@ describe("App", () => { state.saveResetTimerDisplayModeMock.mockResolvedValue(undefined) state.loadMenubarIconStyleMock.mockResolvedValue("provider") state.saveMenubarIconStyleMock.mockResolvedValue(undefined) + state.loadWeeklyWarningThresholdPercentMock.mockResolvedValue(30) + state.saveWeeklyWarningThresholdPercentMock.mockResolvedValue(undefined) state.migrateLegacyTraySettingsMock.mockResolvedValue(undefined) state.loadGlobalShortcutMock.mockResolvedValue(null) state.saveGlobalShortcutMock.mockResolvedValue(undefined) @@ -606,6 +614,48 @@ describe("App", () => { expect(state.traySetTitleMock).not.toHaveBeenCalled() }) + it("switches to weekly tray metric and alert styling when weekly remaining reaches the threshold", async () => { + state.invokeMock.mockImplementation(async (cmd: string) => { + if (cmd === "list_plugins") { + return [ + { + id: "a", + name: "Alpha", + iconUrl: "icon-a", + primaryCandidates: ["Session"], + lines: [ + { type: "progress", label: "Session", scope: "overview" }, + { type: "progress", label: "Weekly", scope: "overview" }, + ], + }, + ] + } + return null + }) + state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["a"], disabled: [] }) + + render() + await waitFor(() => expect(state.startBatchMock).toHaveBeenCalled()) + + state.probeHandlers?.onResult({ + providerId: "a", + displayName: "Alpha", + iconUrl: "icon-a", + lines: [ + { type: "progress", label: "Session", used: 40, limit: 100, format: { kind: "percent" } }, + { type: "progress", label: "Weekly", used: 75, limit: 100, format: { kind: "percent" } }, + ], + }) + + await waitFor(() => { + const latestCall = state.renderTrayBarsIconMock.mock.calls.at(-1)?.[0] + expect(latestCall.tone).toBe("warning") + expect(latestCall.percentText).toBe("!25%") + }) + await waitFor(() => expect(state.traySetIconAsTemplateMock).toHaveBeenLastCalledWith(false)) + await waitFor(() => expect(state.traySetTitleMock).toHaveBeenLastCalledWith("")) + }) + it("uses selected provider on detail view and keeps it on home/settings", async () => { state.invokeMock.mockImplementation(async (cmd: string) => { if (cmd === "list_plugins") { diff --git a/src/App.tsx b/src/App.tsx index d5edc031..c690e8a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -54,6 +54,8 @@ function App() { setDisplayMode, menubarIconStyle, setMenubarIconStyle, + weeklyWarningThresholdPercent, + setWeeklyWarningThresholdPercent, resetTimerDisplayMode, setResetTimerDisplayMode, setGlobalShortcut, @@ -68,6 +70,8 @@ function App() { setDisplayMode: state.setDisplayMode, menubarIconStyle: state.menubarIconStyle, setMenubarIconStyle: state.setMenubarIconStyle, + weeklyWarningThresholdPercent: state.weeklyWarningThresholdPercent, + setWeeklyWarningThresholdPercent: state.setWeeklyWarningThresholdPercent, resetTimerDisplayMode: state.resetTimerDisplayMode, setResetTimerDisplayMode: state.setResetTimerDisplayMode, setGlobalShortcut: state.setGlobalShortcut, @@ -101,6 +105,7 @@ function App() { pluginStates, displayMode, menubarIconStyle, + weeklyWarningThresholdPercent, activeView, }) @@ -117,6 +122,7 @@ function App() { setThemeMode, setDisplayMode, setMenubarIconStyle, + setWeeklyWarningThresholdPercent, setResetTimerDisplayMode, setGlobalShortcut, setStartOnLogin, @@ -133,12 +139,14 @@ function App() { handleResetTimerDisplayModeChange, handleResetTimerDisplayModeToggle, handleMenubarIconStyleChange, + handleWeeklyWarningThresholdPercentChange, } = useSettingsDisplayActions({ setThemeMode, setDisplayMode, resetTimerDisplayMode, setResetTimerDisplayMode, setMenubarIconStyle, + setWeeklyWarningThresholdPercent, scheduleTrayIconUpdate, }) @@ -247,6 +255,7 @@ function App() { onResetTimerDisplayModeChange: handleResetTimerDisplayModeChange, onResetTimerDisplayModeToggle: handleResetTimerDisplayModeToggle, onMenubarIconStyleChange: handleMenubarIconStyleChange, + onWeeklyWarningThresholdPercentChange: handleWeeklyWarningThresholdPercentChange, traySettingsPreview, onGlobalShortcutChange: handleGlobalShortcutChange, onStartOnLoginChange: handleStartOnLoginChange, diff --git a/src/components/app/app-content.test.tsx b/src/components/app/app-content.test.tsx index b6bda4a3..a4d60cac 100644 --- a/src/components/app/app-content.test.tsx +++ b/src/components/app/app-content.test.tsx @@ -62,6 +62,14 @@ function createProps(): AppContentProps { onDisplayModeChange: vi.fn(), onResetTimerDisplayModeChange: vi.fn(), onResetTimerDisplayModeToggle: vi.fn(), + onMenubarIconStyleChange: vi.fn(), + onWeeklyWarningThresholdPercentChange: vi.fn(), + traySettingsPreview: { + bars: [], + providerBars: [], + providerPercentText: "--%", + providerAlertSeverity: "none" as const, + }, onGlobalShortcutChange: vi.fn(), onStartOnLoginChange: vi.fn(), } diff --git a/src/components/app/app-content.tsx b/src/components/app/app-content.tsx index e362fa76..23b03f8c 100644 --- a/src/components/app/app-content.tsx +++ b/src/components/app/app-content.tsx @@ -14,6 +14,7 @@ import type { MenubarIconStyle, ResetTimerDisplayMode, ThemeMode, + WeeklyWarningThresholdPercent, } from "@/lib/settings" type AppContentDerivedProps = { @@ -32,6 +33,7 @@ export type AppContentActionProps = { onResetTimerDisplayModeChange: (mode: ResetTimerDisplayMode) => void onResetTimerDisplayModeToggle: () => void onMenubarIconStyleChange: (value: MenubarIconStyle) => void + onWeeklyWarningThresholdPercentChange: (value: WeeklyWarningThresholdPercent) => void traySettingsPreview: TraySettingsPreview onGlobalShortcutChange: (value: GlobalShortcut) => void onStartOnLoginChange: (value: boolean) => void @@ -52,6 +54,7 @@ export function AppContent({ onResetTimerDisplayModeChange, onResetTimerDisplayModeToggle, onMenubarIconStyleChange, + onWeeklyWarningThresholdPercentChange, traySettingsPreview, onGlobalShortcutChange, onStartOnLoginChange, @@ -67,6 +70,7 @@ export function AppContent({ resetTimerDisplayMode, menubarIconStyle, autoUpdateInterval, + weeklyWarningThresholdPercent, globalShortcut, themeMode, startOnLogin, @@ -76,6 +80,7 @@ export function AppContent({ resetTimerDisplayMode: state.resetTimerDisplayMode, menubarIconStyle: state.menubarIconStyle, autoUpdateInterval: state.autoUpdateInterval, + weeklyWarningThresholdPercent: state.weeklyWarningThresholdPercent, globalShortcut: state.globalShortcut, themeMode: state.themeMode, startOnLogin: state.startOnLogin, @@ -110,6 +115,8 @@ export function AppContent({ onResetTimerDisplayModeChange={onResetTimerDisplayModeChange} menubarIconStyle={menubarIconStyle} onMenubarIconStyleChange={onMenubarIconStyleChange} + weeklyWarningThresholdPercent={weeklyWarningThresholdPercent} + onWeeklyWarningThresholdPercentChange={onWeeklyWarningThresholdPercentChange} 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..c068309e 100644 --- a/src/hooks/app/use-settings-bootstrap.test.ts +++ b/src/hooks/app/use-settings-bootstrap.test.ts @@ -17,6 +17,7 @@ const { loadResetTimerDisplayModeMock, loadStartOnLoginMock, loadThemeModeMock, + loadWeeklyWarningThresholdPercentMock, migrateLegacyTraySettingsMock, normalizePluginSettingsMock, savePluginSettingsMock, @@ -36,6 +37,7 @@ const { loadResetTimerDisplayModeMock: vi.fn(), loadStartOnLoginMock: vi.fn(), loadThemeModeMock: vi.fn(), + loadWeeklyWarningThresholdPercentMock: vi.fn(), migrateLegacyTraySettingsMock: vi.fn(), normalizePluginSettingsMock: vi.fn(), savePluginSettingsMock: vi.fn(), @@ -61,6 +63,7 @@ vi.mock("@/lib/settings", () => ({ DEFAULT_RESET_TIMER_DISPLAY_MODE: "relative", DEFAULT_START_ON_LOGIN: false, DEFAULT_THEME_MODE: "system", + DEFAULT_WEEKLY_WARNING_THRESHOLD_PERCENT: 30, getEnabledPluginIds: getEnabledPluginIdsMock, loadAutoUpdateInterval: loadAutoUpdateIntervalMock, loadDisplayMode: loadDisplayModeMock, @@ -70,6 +73,7 @@ vi.mock("@/lib/settings", () => ({ loadResetTimerDisplayMode: loadResetTimerDisplayModeMock, loadStartOnLogin: loadStartOnLoginMock, loadThemeMode: loadThemeModeMock, + loadWeeklyWarningThresholdPercent: loadWeeklyWarningThresholdPercentMock, migrateLegacyTraySettings: migrateLegacyTraySettingsMock, normalizePluginSettings: normalizePluginSettingsMock, savePluginSettings: savePluginSettingsMock, @@ -88,6 +92,7 @@ function createArgs() { setGlobalShortcut: vi.fn(), setStartOnLogin: vi.fn(), setMenubarIconStyle: vi.fn(), + setWeeklyWarningThresholdPercent: vi.fn(), setLoadingForPlugins: vi.fn(), setErrorForPlugins: vi.fn(), startBatch: vi.fn().mockResolvedValue(undefined), @@ -111,6 +116,7 @@ describe("useSettingsBootstrap", () => { loadResetTimerDisplayModeMock.mockReset() loadStartOnLoginMock.mockReset() loadThemeModeMock.mockReset() + loadWeeklyWarningThresholdPercentMock.mockReset() migrateLegacyTraySettingsMock.mockReset() normalizePluginSettingsMock.mockReset() savePluginSettingsMock.mockReset() @@ -136,6 +142,7 @@ describe("useSettingsBootstrap", () => { loadResetTimerDisplayModeMock.mockResolvedValue("relative") loadGlobalShortcutMock.mockResolvedValue("CommandOrControl+Shift+O") loadMenubarIconStyleMock.mockResolvedValue("provider") + loadWeeklyWarningThresholdPercentMock.mockResolvedValue(30) loadStartOnLoginMock.mockResolvedValue(true) migrateLegacyTraySettingsMock.mockResolvedValue(undefined) savePluginSettingsMock.mockResolvedValue(undefined) diff --git a/src/hooks/app/use-settings-bootstrap.ts b/src/hooks/app/use-settings-bootstrap.ts index fcc7df09..5a97f599 100644 --- a/src/hooks/app/use-settings-bootstrap.ts +++ b/src/hooks/app/use-settings-bootstrap.ts @@ -15,6 +15,7 @@ import { DEFAULT_RESET_TIMER_DISPLAY_MODE, DEFAULT_START_ON_LOGIN, DEFAULT_THEME_MODE, + DEFAULT_WEEKLY_WARNING_THRESHOLD_PERCENT, getEnabledPluginIds, loadAutoUpdateInterval, loadDisplayMode, @@ -25,6 +26,7 @@ import { loadResetTimerDisplayMode, loadStartOnLogin, loadThemeMode, + loadWeeklyWarningThresholdPercent, normalizePluginSettings, savePluginSettings, type AutoUpdateIntervalMinutes, @@ -34,6 +36,7 @@ import { type PluginSettings, type ResetTimerDisplayMode, type ThemeMode, + type WeeklyWarningThresholdPercent, } from "@/lib/settings" type UseSettingsBootstrapArgs = { @@ -46,6 +49,7 @@ type UseSettingsBootstrapArgs = { setGlobalShortcut: (value: GlobalShortcut) => void setStartOnLogin: (value: boolean) => void setMenubarIconStyle: (value: MenubarIconStyle) => void + setWeeklyWarningThresholdPercent: (value: WeeklyWarningThresholdPercent) => void setLoadingForPlugins: (ids: string[]) => void setErrorForPlugins: (ids: string[], error: string) => void startBatch: (pluginIds?: string[]) => Promise @@ -61,6 +65,7 @@ export function useSettingsBootstrap({ setGlobalShortcut, setStartOnLogin, setMenubarIconStyle, + setWeeklyWarningThresholdPercent, setLoadingForPlugins, setErrorForPlugins, startBatch, @@ -153,6 +158,13 @@ export function useSettingsBootstrap({ console.error("Failed to load menubar icon style:", error) } + let storedWeeklyWarningThresholdPercent = DEFAULT_WEEKLY_WARNING_THRESHOLD_PERCENT + try { + storedWeeklyWarningThresholdPercent = await loadWeeklyWarningThresholdPercent() + } catch (error) { + console.error("Failed to load weekly warning threshold:", error) + } + if (isMounted) { setPluginSettings(normalized) setAutoUpdateInterval(storedInterval) @@ -162,6 +174,7 @@ export function useSettingsBootstrap({ setGlobalShortcut(storedGlobalShortcut) setStartOnLogin(storedStartOnLogin) setMenubarIconStyle(storedMenubarIconStyle) + setWeeklyWarningThresholdPercent(storedWeeklyWarningThresholdPercent) const enabledIds = getEnabledPluginIds(normalized) setLoadingForPlugins(enabledIds) @@ -192,6 +205,7 @@ export function useSettingsBootstrap({ setGlobalShortcut, setLoadingForPlugins, setMenubarIconStyle, + setWeeklyWarningThresholdPercent, 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 7d0db6a4..5a1e9c2f 100644 --- a/src/hooks/app/use-settings-display-actions.test.ts +++ b/src/hooks/app/use-settings-display-actions.test.ts @@ -4,13 +4,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest" const { trackMock, saveDisplayModeMock, + saveMenubarIconStyleMock, saveResetTimerDisplayModeMock, saveThemeModeMock, + saveWeeklyWarningThresholdPercentMock, } = vi.hoisted(() => ({ trackMock: vi.fn(), saveThemeModeMock: vi.fn(), saveDisplayModeMock: vi.fn(), + saveMenubarIconStyleMock: vi.fn(), saveResetTimerDisplayModeMock: vi.fn(), + saveWeeklyWarningThresholdPercentMock: vi.fn(), })) vi.mock("@/lib/analytics", () => ({ @@ -20,7 +24,9 @@ vi.mock("@/lib/analytics", () => ({ vi.mock("@/lib/settings", () => ({ saveThemeMode: saveThemeModeMock, saveDisplayMode: saveDisplayModeMock, + saveMenubarIconStyle: saveMenubarIconStyleMock, saveResetTimerDisplayMode: saveResetTimerDisplayModeMock, + saveWeeklyWarningThresholdPercent: saveWeeklyWarningThresholdPercentMock, })) import { useSettingsDisplayActions } from "@/hooks/app/use-settings-display-actions" @@ -30,16 +36,22 @@ describe("useSettingsDisplayActions", () => { trackMock.mockReset() saveThemeModeMock.mockReset() saveDisplayModeMock.mockReset() + saveMenubarIconStyleMock.mockReset() saveResetTimerDisplayModeMock.mockReset() + saveWeeklyWarningThresholdPercentMock.mockReset() saveThemeModeMock.mockResolvedValue(undefined) saveDisplayModeMock.mockResolvedValue(undefined) + saveMenubarIconStyleMock.mockResolvedValue(undefined) saveResetTimerDisplayModeMock.mockResolvedValue(undefined) + saveWeeklyWarningThresholdPercentMock.mockResolvedValue(undefined) }) it("tracks and applies display-related setting changes", () => { const setThemeMode = vi.fn() const setDisplayMode = vi.fn() const setResetTimerDisplayMode = vi.fn() + const setMenubarIconStyle = vi.fn() + const setWeeklyWarningThresholdPercent = vi.fn() const scheduleTrayIconUpdate = vi.fn() const { result } = renderHook(() => @@ -48,6 +60,8 @@ describe("useSettingsDisplayActions", () => { setDisplayMode, resetTimerDisplayMode: "relative", setResetTimerDisplayMode, + setMenubarIconStyle, + setWeeklyWarningThresholdPercent, scheduleTrayIconUpdate, }) ) @@ -56,6 +70,8 @@ describe("useSettingsDisplayActions", () => { result.current.handleThemeModeChange("dark") result.current.handleDisplayModeChange("used") result.current.handleResetTimerDisplayModeChange("absolute") + result.current.handleMenubarIconStyleChange("bars") + result.current.handleWeeklyWarningThresholdPercentChange(40) }) expect(trackMock).toHaveBeenCalledWith("setting_changed", { setting: "theme", value: "dark" }) @@ -67,15 +83,27 @@ describe("useSettingsDisplayActions", () => { setting: "reset_timer_display_mode", value: "absolute", }) + expect(trackMock).toHaveBeenCalledWith("setting_changed", { + setting: "menubar_icon_style", + value: "bars", + }) + expect(trackMock).toHaveBeenCalledWith("setting_changed", { + setting: "weekly_warning_threshold_percent", + value: "40", + }) expect(setThemeMode).toHaveBeenCalledWith("dark") expect(setDisplayMode).toHaveBeenCalledWith("used") expect(setResetTimerDisplayMode).toHaveBeenCalledWith("absolute") + expect(setMenubarIconStyle).toHaveBeenCalledWith("bars") + expect(setWeeklyWarningThresholdPercent).toHaveBeenCalledWith(40) expect(scheduleTrayIconUpdate).toHaveBeenCalledWith("settings", 0) expect(saveThemeModeMock).toHaveBeenCalledWith("dark") expect(saveDisplayModeMock).toHaveBeenCalledWith("used") expect(saveResetTimerDisplayModeMock).toHaveBeenCalledWith("absolute") + expect(saveMenubarIconStyleMock).toHaveBeenCalledWith("bars") + expect(saveWeeklyWarningThresholdPercentMock).toHaveBeenCalledWith(40) }) it("toggles reset timer mode in both directions", () => { @@ -88,6 +116,8 @@ describe("useSettingsDisplayActions", () => { setDisplayMode: vi.fn(), resetTimerDisplayMode: mode, setResetTimerDisplayMode, + setMenubarIconStyle: vi.fn(), + setWeeklyWarningThresholdPercent: vi.fn(), scheduleTrayIconUpdate: vi.fn(), }), { initialProps: { mode: "relative" as const } } @@ -109,10 +139,14 @@ describe("useSettingsDisplayActions", () => { const themeError = new Error("theme failed") const displayError = new Error("display failed") const resetError = new Error("reset failed") + const menubarError = new Error("menubar failed") + const weeklyWarningError = new Error("weekly warning failed") const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) saveThemeModeMock.mockRejectedValueOnce(themeError) saveDisplayModeMock.mockRejectedValueOnce(displayError) saveResetTimerDisplayModeMock.mockRejectedValueOnce(resetError) + saveMenubarIconStyleMock.mockRejectedValueOnce(menubarError) + saveWeeklyWarningThresholdPercentMock.mockRejectedValueOnce(weeklyWarningError) const { result } = renderHook(() => useSettingsDisplayActions({ @@ -120,6 +154,8 @@ describe("useSettingsDisplayActions", () => { setDisplayMode: vi.fn(), resetTimerDisplayMode: "relative", setResetTimerDisplayMode: vi.fn(), + setMenubarIconStyle: vi.fn(), + setWeeklyWarningThresholdPercent: vi.fn(), scheduleTrayIconUpdate: vi.fn(), }) ) @@ -128,12 +164,16 @@ describe("useSettingsDisplayActions", () => { result.current.handleThemeModeChange("light") result.current.handleDisplayModeChange("left") result.current.handleResetTimerDisplayModeChange("relative") + result.current.handleMenubarIconStyleChange("provider") + result.current.handleWeeklyWarningThresholdPercentChange(30) }) 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 icon style:", menubarError) + expect(errorSpy).toHaveBeenCalledWith("Failed to save weekly warning threshold:", weeklyWarningError) }) errorSpy.mockRestore() diff --git a/src/hooks/app/use-settings-display-actions.ts b/src/hooks/app/use-settings-display-actions.ts index 65dcc886..d9ed64bc 100644 --- a/src/hooks/app/use-settings-display-actions.ts +++ b/src/hooks/app/use-settings-display-actions.ts @@ -5,10 +5,12 @@ import { saveMenubarIconStyle, saveResetTimerDisplayMode, saveThemeMode, + saveWeeklyWarningThresholdPercent, type DisplayMode, type MenubarIconStyle, type ResetTimerDisplayMode, type ThemeMode, + type WeeklyWarningThresholdPercent, } from "@/lib/settings" type ScheduleTrayIconUpdate = (reason: "probe" | "settings" | "init", delayMs?: number) => void @@ -19,6 +21,7 @@ type UseSettingsDisplayActionsArgs = { resetTimerDisplayMode: ResetTimerDisplayMode setResetTimerDisplayMode: (value: ResetTimerDisplayMode) => void setMenubarIconStyle: (value: MenubarIconStyle) => void + setWeeklyWarningThresholdPercent: (value: WeeklyWarningThresholdPercent) => void scheduleTrayIconUpdate: ScheduleTrayIconUpdate } @@ -28,6 +31,7 @@ export function useSettingsDisplayActions({ resetTimerDisplayMode, setResetTimerDisplayMode, setMenubarIconStyle, + setWeeklyWarningThresholdPercent, scheduleTrayIconUpdate, }: UseSettingsDisplayActionsArgs) { const handleThemeModeChange = useCallback((mode: ThemeMode) => { @@ -69,11 +73,21 @@ export function useSettingsDisplayActions({ }) }, [scheduleTrayIconUpdate, setMenubarIconStyle]) + const handleWeeklyWarningThresholdPercentChange = useCallback((value: WeeklyWarningThresholdPercent) => { + track("setting_changed", { setting: "weekly_warning_threshold_percent", value: String(value) }) + setWeeklyWarningThresholdPercent(value) + scheduleTrayIconUpdate("settings", 0) + void saveWeeklyWarningThresholdPercent(value).catch((error) => { + console.error("Failed to save weekly warning threshold:", error) + }) + }, [scheduleTrayIconUpdate, setWeeklyWarningThresholdPercent]) + return { handleThemeModeChange, handleDisplayModeChange, handleResetTimerDisplayModeChange, handleResetTimerDisplayModeToggle, handleMenubarIconStyleChange, + handleWeeklyWarningThresholdPercentChange, } } diff --git a/src/hooks/app/use-tray-icon.ts b/src/hooks/app/use-tray-icon.ts index a98acc8b..15462ecf 100644 --- a/src/hooks/app/use-tray-icon.ts +++ b/src/hooks/app/use-tray-icon.ts @@ -2,8 +2,14 @@ import { useCallback, useEffect, useRef, useState } from "react" import { resolveResource } from "@tauri-apps/api/path" import { TrayIcon } from "@tauri-apps/api/tray" import type { PluginMeta } from "@/lib/plugin-types" -import type { DisplayMode, MenubarIconStyle, PluginSettings } from "@/lib/settings" +import type { + DisplayMode, + MenubarIconStyle, + PluginSettings, + WeeklyWarningThresholdPercent, +} from "@/lib/settings" import { getEnabledPluginIds } from "@/lib/settings" +import type { TrayAlertSeverity } from "@/lib/tray-alert" import { getTrayIconSizePx, renderTrayBarsIcon } from "@/lib/tray-bars-icon" import { getTrayPrimaryBars, type TrayPrimaryBar } from "@/lib/tray-primary-progress" import { formatTrayPercentText, formatTrayTooltip } from "@/lib/tray-tooltip" @@ -17,6 +23,7 @@ type UseTrayIconArgs = { pluginStates: Record displayMode: DisplayMode menubarIconStyle: MenubarIconStyle + weeklyWarningThresholdPercent: WeeklyWarningThresholdPercent activeView: string } @@ -25,36 +32,50 @@ export type TraySettingsPreview = { providerBars: TrayPrimaryBar[] providerIconUrl?: string providerPercentText: string + providerAlertSeverity: TrayAlertSeverity } const EMPTY_TRAY_SETTINGS_PREVIEW: TraySettingsPreview = { bars: [], providerBars: [], providerPercentText: "--%", + providerAlertSeverity: "none", } function isSameTraySettingsPreview(a: TraySettingsPreview, b: TraySettingsPreview): boolean { if (a.providerIconUrl !== b.providerIconUrl) return false if (a.providerPercentText !== b.providerPercentText) return false + if (a.providerAlertSeverity !== b.providerAlertSeverity) return false if (a.bars.length !== b.bars.length) return false if (a.providerBars.length !== b.providerBars.length) return false for (let i = 0; i < a.bars.length; i += 1) { if (a.bars[i]?.id !== b.bars[i]?.id) return false + if (a.bars[i]?.label !== b.bars[i]?.label) return false if (a.bars[i]?.fraction !== b.bars[i]?.fraction) return false + if (a.bars[i]?.warningSeverity !== b.bars[i]?.warningSeverity) return false } for (let i = 0; i < a.providerBars.length; i += 1) { if (a.providerBars[i]?.id !== b.providerBars[i]?.id) return false + if (a.providerBars[i]?.label !== b.providerBars[i]?.label) return false if (a.providerBars[i]?.fraction !== b.providerBars[i]?.fraction) return false + if (a.providerBars[i]?.warningSeverity !== b.providerBars[i]?.warningSeverity) return false } return true } +function getTrayAlertSeverity(bars: TrayPrimaryBar[]): TrayAlertSeverity { + if (bars.some((bar) => bar.warningSeverity === "critical")) return "critical" + if (bars.some((bar) => bar.warningSeverity === "warning")) return "warning" + return "none" +} + export function useTrayIcon({ pluginsMeta, pluginSettings, pluginStates, displayMode, menubarIconStyle, + weeklyWarningThresholdPercent, activeView, }: UseTrayIconArgs) { const trayRef = useRef(null) @@ -72,6 +93,7 @@ export function useTrayIcon({ const pluginStatesRef = useRef(pluginStates) const displayModeRef = useRef(displayMode) const menubarIconStyleRef = useRef(menubarIconStyle) + const weeklyWarningThresholdPercentRef = useRef(weeklyWarningThresholdPercent) const activeViewRef = useRef(activeView) const lastTrayProviderIdRef = useRef(null) @@ -95,6 +117,10 @@ export function useTrayIcon({ menubarIconStyleRef.current = menubarIconStyle }, [menubarIconStyle]) + useEffect(() => { + weeklyWarningThresholdPercentRef.current = weeklyWarningThresholdPercent + }, [weeklyWarningThresholdPercent]) + useEffect(() => { activeViewRef.current = activeView }, [activeView]) @@ -208,6 +234,7 @@ export function useTrayIcon({ pluginStates: pluginStatesRef.current, maxBars: 4, displayMode: displayModeRef.current, + weeklyWarningThresholdPercent: weeklyWarningThresholdPercentRef.current, }) const providerBars = trayProviderId @@ -218,6 +245,7 @@ export function useTrayIcon({ maxBars: 1, displayMode: displayModeRef.current, pluginId: trayProviderId, + weeklyWarningThresholdPercent: weeklyWarningThresholdPercentRef.current, }) : [] @@ -225,12 +253,14 @@ export function useTrayIcon({ ? pluginsMetaRef.current.find((plugin) => plugin.id === trayProviderId)?.iconUrl : undefined const providerPercentText = formatTrayPercentText(providerBars[0]?.fraction) + const providerAlertSeverity = providerBars[0]?.warningSeverity ?? "none" const nextPreview: TraySettingsPreview = { bars: barsForPreview, providerBars, providerIconUrl, providerPercentText, + providerAlertSeverity, } setTraySettingsPreview((prev) => isSameTraySettingsPreview(prev, nextPreview) ? prev : nextPreview @@ -242,20 +272,23 @@ export function useTrayIcon({ pluginStates: pluginStatesRef.current, maxBars: 20, // Show more in tooltip displayMode: displayModeRef.current, + weeklyWarningThresholdPercent: weeklyWarningThresholdPercentRef.current, }) const tooltip = formatTrayTooltip(tooltipBars, pluginsMetaRef.current) const updateTooltip = () => setTrayTooltip(tooltip) if (style === "bars") { + const barsAlertSeverity = getTrayAlertSeverity(barsForPreview) renderTrayBarsIcon({ bars: barsForPreview, sizePx, style: "bars", + tone: barsAlertSeverity, }) .then(async (img) => { await tray.setIcon(img) - await tray.setIconAsTemplate(true) - await setTrayTitle("") + await tray.setIconAsTemplate(barsAlertSeverity === "none") + await setTrayTitle(barsAlertSeverity === "none" ? "" : "!") await updateTooltip() }) .catch((e) => { @@ -279,11 +312,12 @@ export function useTrayIcon({ sizePx, style: "donut", providerIconUrl, + tone: providerAlertSeverity, }) .then(async (img) => { await tray.setIcon(img) - await tray.setIconAsTemplate(true) - await setTrayTitle("") + await tray.setIconAsTemplate(providerAlertSeverity === "none") + await setTrayTitle(providerAlertSeverity === "none" ? "" : "!") await updateTooltip() }) .catch((e) => { @@ -295,17 +329,26 @@ export function useTrayIcon({ return } + const shouldRenderProviderTextInIcon = + providerAlertSeverity !== "none" || !supportsNativeTrayTitle + const providerPercentTextForIcon = shouldRenderProviderTextInIcon + ? providerAlertSeverity === "none" + ? providerPercentText + : `!${providerPercentText}` + : undefined + renderTrayBarsIcon({ bars: providerBars, sizePx, style: "provider", - percentText: supportsNativeTrayTitle ? undefined : providerPercentText, + percentText: providerPercentTextForIcon, providerIconUrl, + tone: providerAlertSeverity, }) .then(async (img) => { await tray.setIcon(img) - await tray.setIconAsTemplate(true) - await setTrayTitle(providerPercentText) + await tray.setIconAsTemplate(providerAlertSeverity === "none") + await setTrayTitle(shouldRenderProviderTextInIcon ? "" : providerPercentText) await updateTooltip() }) .catch((e) => { @@ -357,7 +400,7 @@ export function useTrayIcon({ useEffect(() => { if (!trayReady) return scheduleTrayIconUpdate("settings", 0) - }, [activeView, menubarIconStyle, scheduleTrayIconUpdate, trayReady]) + }, [activeView, menubarIconStyle, scheduleTrayIconUpdate, trayReady, weeklyWarningThresholdPercent]) useEffect(() => { return () => { diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 1c686863..fa2a8dd9 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -8,6 +8,7 @@ import { DEFAULT_RESET_TIMER_DISPLAY_MODE, DEFAULT_START_ON_LOGIN, DEFAULT_THEME_MODE, + DEFAULT_WEEKLY_WARNING_THRESHOLD_PERCENT, arePluginSettingsEqual, getEnabledPluginIds, loadAutoUpdateInterval, @@ -17,6 +18,7 @@ import { loadPluginSettings, loadResetTimerDisplayMode, loadStartOnLogin, + loadWeeklyWarningThresholdPercent, migrateLegacyTraySettings, loadThemeMode, normalizePluginSettings, @@ -28,6 +30,7 @@ import { saveResetTimerDisplayMode, saveStartOnLogin, saveThemeMode, + saveWeeklyWarningThresholdPercent, } from "@/lib/settings" import type { PluginMeta } from "@/lib/plugin-types" @@ -261,6 +264,22 @@ describe("settings", () => { await expect(loadMenubarIconStyle()).resolves.toBe(DEFAULT_MENUBAR_ICON_STYLE) }) + it("loads default weekly warning threshold when missing", async () => { + await expect(loadWeeklyWarningThresholdPercent()).resolves.toBe( + DEFAULT_WEEKLY_WARNING_THRESHOLD_PERCENT + ) + }) + + it("loads stored weekly warning threshold", async () => { + storeState.set("weeklyWarningThresholdPercent", 40) + await expect(loadWeeklyWarningThresholdPercent()).resolves.toBe(40) + }) + + it("saves weekly warning threshold", async () => { + await saveWeeklyWarningThresholdPercent(20) + await expect(loadWeeklyWarningThresholdPercent()).resolves.toBe(20) + }) + 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..e2fe9f1e 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -20,6 +20,8 @@ export type ResetTimerDisplayMode = "relative" | "absolute"; export type MenubarIconStyle = "provider" | "bars" | "donut"; +export type WeeklyWarningThresholdPercent = 10 | 20 | 30 | 40 | 50; + export type GlobalShortcut = string | null; const SETTINGS_STORE_PATH = "settings.json"; @@ -29,6 +31,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 WEEKLY_WARNING_THRESHOLD_KEY = "weeklyWarningThresholdPercent"; const LEGACY_TRAY_ICON_STYLE_KEY = "trayIconStyle"; const LEGACY_TRAY_SHOW_PERCENTAGE_KEY = "trayShowPercentage"; const GLOBAL_SHORTCUT_KEY = "globalShortcut"; @@ -39,6 +42,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_WEEKLY_WARNING_THRESHOLD_PERCENT: WeeklyWarningThresholdPercent = 30; export const DEFAULT_GLOBAL_SHORTCUT: GlobalShortcut = null; export const DEFAULT_START_ON_LOGIN = false; @@ -47,6 +51,7 @@ const THEME_MODES: ThemeMode[] = ["system", "light", "dark"]; const DISPLAY_MODES: DisplayMode[] = ["used", "left"]; const RESET_TIMER_DISPLAY_MODES: ResetTimerDisplayMode[] = ["relative", "absolute"]; const MENUBAR_ICON_STYLES: MenubarIconStyle[] = ["provider", "donut", "bars"]; +const WEEKLY_WARNING_THRESHOLD_OPTIONS = [10, 20, 30, 40, 50] as const; export const MENUBAR_ICON_STYLE_OPTIONS: { value: MenubarIconStyle; label: string }[] = [ { value: "provider", label: "Plugin" }, @@ -76,6 +81,14 @@ export const RESET_TIMER_DISPLAY_OPTIONS: { value: ResetTimerDisplayMode; label: { value: "absolute", label: "Absolute" }, ]; +export const WEEKLY_WARNING_THRESHOLD_PERCENT_OPTIONS: { + value: WeeklyWarningThresholdPercent + label: string +}[] = WEEKLY_WARNING_THRESHOLD_OPTIONS.map((value) => ({ + value, + label: `${value}%`, +})); + const store = new LazyStore(SETTINGS_STORE_PATH); const DEFAULT_ENABLED_PLUGINS = new Set(["claude", "codex", "cursor"]); @@ -232,6 +245,26 @@ export async function saveMenubarIconStyle(style: MenubarIconStyle): Promise { + const stored = await store.get(WEEKLY_WARNING_THRESHOLD_KEY); + if (isWeeklyWarningThresholdPercent(stored)) return stored; + return DEFAULT_WEEKLY_WARNING_THRESHOLD_PERCENT; +} + +export async function saveWeeklyWarningThresholdPercent( + value: WeeklyWarningThresholdPercent +): Promise { + await store.set(WEEKLY_WARNING_THRESHOLD_KEY, value); + await store.save(); +} + type LegacyStoreWithDelete = { delete?: (key: string) => Promise; }; diff --git a/src/lib/tray-alert.ts b/src/lib/tray-alert.ts new file mode 100644 index 00000000..d038824b --- /dev/null +++ b/src/lib/tray-alert.ts @@ -0,0 +1,115 @@ +// FILE: tray-alert.ts +// Purpose: Centralizes weekly-warning rules for the menubar/tray metric selection. +// Layer: UI utility +// Exports: tray alert severity helpers + primary metric selection for tray rendering. + +import type { PluginMeta, PluginOutput } from "@/lib/plugin-types" +import type { WeeklyWarningThresholdPercent } from "@/lib/settings" +import { clamp01 } from "@/lib/utils" + +export type TrayAlertSeverity = "none" | "warning" | "critical" + +type ProgressLine = Extract< + PluginOutput["lines"][number], + { type: "progress"; label: string; used: number; limit: number } +> + +export type TrayPrimaryMetricSelection = { + line: ProgressLine | null + warningSeverity: TrayAlertSeverity + weeklyRemainingPercent: number | null +} + +const WEEKLY_LABEL_RE = /\bweek/i + +function isProgressLine(line: PluginOutput["lines"][number]): line is ProgressLine { + return line.type === "progress" +} + +function findMatchingProgressLine( + lines: PluginOutput["lines"], + labels: string[] +): ProgressLine | null { + for (const label of labels) { + const match = lines.find( + (line): line is ProgressLine => isProgressLine(line) && line.label === label + ) + if (match) return match + } + return null +} + +function getWeeklyOverviewLabels(meta: PluginMeta): string[] { + return meta.lines + .filter((line) => line.type === "progress" && line.scope === "overview" && WEEKLY_LABEL_RE.test(line.label)) + .map((line) => line.label) +} + +function findWeeklyProgressLine(meta: PluginMeta, data: PluginOutput): ProgressLine | null { + const metaWeekly = findMatchingProgressLine(data.lines, getWeeklyOverviewLabels(meta)) + if (metaWeekly) return metaWeekly + + return ( + data.lines.find( + (line): line is ProgressLine => isProgressLine(line) && WEEKLY_LABEL_RE.test(line.label) + ) ?? null + ) +} + +function getRemainingPercent(line: ProgressLine | null): number | null { + if (!line || !Number.isFinite(line.limit) || line.limit <= 0 || !Number.isFinite(line.used)) { + return null + } + return Math.round(clamp01((line.limit - line.used) / line.limit) * 100) +} + +function getCriticalThresholdPercent( + thresholdPercent: WeeklyWarningThresholdPercent +): number { + return Math.max(5, Math.round(thresholdPercent / 2)) +} + +// Promotes the weekly metric once remaining weekly budget crosses the configured threshold. +export function selectTrayPrimaryMetric(args: { + meta: PluginMeta + data: PluginOutput | null + weeklyWarningThresholdPercent: WeeklyWarningThresholdPercent +}): TrayPrimaryMetricSelection { + const { meta, data, weeklyWarningThresholdPercent } = args + if (!data) { + return { + line: null, + warningSeverity: "none", + weeklyRemainingPercent: null, + } + } + + const primaryLine = findMatchingProgressLine(data.lines, meta.primaryCandidates ?? []) + const weeklyLine = findWeeklyProgressLine(meta, data) + const weeklyRemainingPercent = getRemainingPercent(weeklyLine) + + let warningSeverity: TrayAlertSeverity = "none" + if (weeklyRemainingPercent !== null) { + const criticalThresholdPercent = getCriticalThresholdPercent(weeklyWarningThresholdPercent) + if (weeklyRemainingPercent <= criticalThresholdPercent) { + warningSeverity = "critical" + } else if (weeklyRemainingPercent <= weeklyWarningThresholdPercent) { + warningSeverity = "warning" + } + } + + const line = + primaryLine && + weeklyLine && + primaryLine.label !== weeklyLine.label && + warningSeverity !== "none" + ? weeklyLine + : primaryLine + + return { + line, + warningSeverity, + weeklyRemainingPercent, + } +} + diff --git a/src/lib/tray-bars-icon.test.ts b/src/lib/tray-bars-icon.test.ts index a1ae1df8..049a3eec 100644 --- a/src/lib/tray-bars-icon.test.ts +++ b/src/lib/tray-bars-icon.test.ts @@ -37,7 +37,7 @@ describe("tray-bars-icon", () => { it("style=bars renders bar SVG elements and no image", () => { const svg = makeTrayBarsSvg({ - bars: [{ id: "a", fraction: 0.5 }], + bars: [{ id: "a", fraction: 0.5, warningSeverity: "none" }], sizePx: 36, style: "bars", }) @@ -59,7 +59,7 @@ describe("tray-bars-icon", () => { it("style=bars with high-end quantized fraction (0.95) renders bars (rect and path)", () => { const svg = makeTrayBarsSvg({ - bars: [{ id: "a", fraction: 0.95 }], + bars: [{ id: "a", fraction: 0.95, warningSeverity: "none" }], sizePx: 36, style: "bars", }) @@ -70,7 +70,7 @@ describe("tray-bars-icon", () => { it("style=donut renders ring arc and centered provider icon", () => { const svg = makeTrayBarsSvg({ - bars: [{ id: "a", fraction: 0.42 }], + bars: [{ id: "a", fraction: 0.42, warningSeverity: "none" }], sizePx: 36, style: "donut", providerIconUrl: "data:image/svg+xml;base64,ABC", @@ -82,7 +82,7 @@ describe("tray-bars-icon", () => { it("style=donut falls back to center glyph when provider icon is missing", () => { const svg = makeTrayBarsSvg({ - bars: [{ id: "a", fraction: 0.42 }], + bars: [{ id: "a", fraction: 0.42, warningSeverity: "none" }], sizePx: 36, style: "donut", }) @@ -135,6 +135,16 @@ describe("tray-bars-icon", () => { expect(svg).toContain(">70%") }) + it("uses warning colors when a tone is provided", () => { + const svg = makeTrayBarsSvg({ + bars: [{ id: "a", fraction: 0.25, warningSeverity: "warning" }], + sizePx: 18, + style: "bars", + tone: "warning", + }) + expect(svg).toContain("#d97706") + }) + it("renderTrayBarsIcon rasterizes SVG to an Image using canvas", async () => { const originalImage = window.Image const originalCreateElement = document.createElement.bind(document) diff --git a/src/lib/tray-bars-icon.ts b/src/lib/tray-bars-icon.ts index 60765c1c..1ef527dd 100644 --- a/src/lib/tray-bars-icon.ts +++ b/src/lib/tray-bars-icon.ts @@ -1,5 +1,6 @@ import { Image } from "@tauri-apps/api/image" import type { MenubarIconStyle } from "@/lib/settings" +import type { TrayAlertSeverity } from "@/lib/tray-alert" import type { TrayPrimaryBar } from "@/lib/tray-primary-progress" const PROVIDER_ICON_SHRINK_PX = 1 @@ -8,6 +9,12 @@ const BARS_TRACK_OPACITY = 0.16 const BARS_REMAINDER_OPACITY = 0.24 const BARS_FILL_OPACITY = 1 +const TRAY_TONE_COLORS: Record = { + none: "#000000", + warning: "#d97706", + critical: "#dc2626", +} + function rgbaToImageDataBytes(rgba: Uint8ClampedArray): Uint8Array { // Image.new expects Uint8Array. Uint8ClampedArray shares the same buffer layout. return new Uint8Array(rgba.buffer) @@ -182,8 +189,9 @@ export function makeTrayBarsSvg(args: { style?: MenubarIconStyle percentText?: string providerIconUrl?: string + tone?: TrayAlertSeverity }): string { - const { bars, sizePx, style = "provider", percentText, providerIconUrl } = args + const { bars, sizePx, style = "provider", percentText, providerIconUrl, tone = "none" } = args const barsForStyle = style === "bars" ? bars : bars.slice(0, 1) // Intentionally render a single empty track when bars mode has no data yet // so the tray icon keeps a stable shape during loading/initialization. @@ -198,6 +206,7 @@ export function makeTrayBarsSvg(args: { const width = layout.width const height = layout.height const trackW = layout.barsWidth + const accentColor = TRAY_TONE_COLORS[tone] const parts: string[] = [] parts.push( @@ -221,7 +230,7 @@ export function makeTrayBarsSvg(args: { const radius = Math.max(2, iconSize / 2 - 1.5) const strokeW = Math.max(1.5, Math.round(iconSize * 0.14)) parts.push( - `` + `` ) } } else if (style === "donut") { @@ -240,7 +249,7 @@ export function makeTrayBarsSvg(args: { const fallbackR = Math.max(2, iconSize / 2 - 1.5) const fallbackSW = Math.max(1.5, Math.round(iconSize * 0.14)) parts.push( - `` + `` ) } @@ -253,7 +262,7 @@ export function makeTrayBarsSvg(args: { const radius = Math.max(1, Math.floor(chartSize / 2 - strokeW / 2) + 0.5) parts.push( - `` + `` ) const fraction = barsForStyle[0]?.fraction @@ -263,7 +272,7 @@ export function makeTrayBarsSvg(args: { const circumference = 2 * Math.PI * radius const dash = circumference * clamped parts.push( - `` + `` ) } } @@ -290,7 +299,7 @@ export function makeTrayBarsSvg(args: { const x = layout.barsX parts.push( - `` + `` ) const fraction = bar?.fraction @@ -300,7 +309,7 @@ export function makeTrayBarsSvg(args: { const movingEdgeRadius = Math.max(0, Math.floor(rx * 0.35)) if (fillW >= trackW) { parts.push( - `` + `` ) } else { const fillPath = makeRoundedBarPath({ @@ -311,7 +320,7 @@ export function makeTrayBarsSvg(args: { leftRadius: rx, rightRadius: movingEdgeRadius, }) - parts.push(``) + parts.push(``) } } @@ -325,7 +334,7 @@ export function makeTrayBarsSvg(args: { leftRadius: Math.max(0, Math.floor(rx * 0.2)), rightRadius: rx, }) - parts.push(``) + parts.push(``) } } } @@ -333,7 +342,7 @@ export function makeTrayBarsSvg(args: { if (text) { parts.push( - `${escapeXmlText(text)}` + `${escapeXmlText(text)}` ) } @@ -380,8 +389,9 @@ export async function renderTrayBarsIcon(args: { style?: MenubarIconStyle percentText?: string providerIconUrl?: string + tone?: TrayAlertSeverity }): Promise { - const { bars, sizePx, style = "provider", percentText, providerIconUrl } = args + const { bars, sizePx, style = "provider", percentText, providerIconUrl, tone = "none" } = args const text = normalizePercentText(percentText) const svg = makeTrayBarsSvg({ bars, @@ -389,6 +399,7 @@ export async function renderTrayBarsIcon(args: { style, percentText: text, providerIconUrl, + tone, }) const layout = getSvgLayout({ sizePx, diff --git a/src/lib/tray-primary-progress.test.ts b/src/lib/tray-primary-progress.test.ts index f2989a5b..75db3a18 100644 --- a/src/lib/tray-primary-progress.test.ts +++ b/src/lib/tray-primary-progress.test.ts @@ -72,7 +72,7 @@ describe("getTrayPrimaryBars", () => { pluginId: "b", }) - expect(bars).toEqual([{ id: "b", fraction: 0.75 }]) + expect(bars).toEqual([{ id: "b", label: "Session", fraction: 0.75, warningSeverity: "none" }]) }) it("includes plugins with primary candidates even when no data (fraction undefined)", () => { @@ -89,7 +89,7 @@ describe("getTrayPrimaryBars", () => { pluginSettings: { order: ["a"], disabled: [] }, pluginStates: { a: { data: null, loading: false, error: null } }, }) - expect(bars).toEqual([{ id: "a", fraction: undefined }]) + expect(bars).toEqual([{ id: "a", label: undefined, fraction: undefined, warningSeverity: "none" }]) }) it("computes fraction from matching progress label and clamps 0..1", () => { @@ -127,7 +127,7 @@ describe("getTrayPrimaryBars", () => { }, }) - expect(bars).toEqual([{ id: "a", fraction: 1 }]) + expect(bars).toEqual([{ id: "a", label: "Plan usage", fraction: 1, warningSeverity: "none" }]) }) it("does not compute fraction when limit is 0", () => { @@ -163,7 +163,7 @@ describe("getTrayPrimaryBars", () => { }, }, }) - expect(bars).toEqual([{ id: "a", fraction: undefined }]) + expect(bars).toEqual([{ id: "a", label: "Plan usage", fraction: undefined, warningSeverity: "none" }]) }) it("respects displayMode=left", () => { @@ -200,7 +200,7 @@ describe("getTrayPrimaryBars", () => { }, }, }) - expect(bars).toEqual([{ id: "a", fraction: 0.75 }]) + expect(bars).toEqual([{ id: "a", label: "Session", fraction: 0.75, warningSeverity: "none" }]) }) it("picks first available candidate from primaryCandidates", () => { @@ -238,7 +238,7 @@ describe("getTrayPrimaryBars", () => { }, }, }) - expect(bars).toEqual([{ id: "a", fraction: 0.5 }]) + expect(bars).toEqual([{ id: "a", label: "Plan usage", fraction: 0.5, warningSeverity: "none" }]) }) it("uses first candidate when both are available", () => { @@ -283,7 +283,105 @@ describe("getTrayPrimaryBars", () => { }, }) // Should use Credits (20/100 = 0.2), not Plan usage (80/100 = 0.8) - expect(bars).toEqual([{ id: "a", fraction: 0.2 }]) + expect(bars).toEqual([{ id: "a", label: "Credits", fraction: 0.2, warningSeverity: "none" }]) + }) + + it("switches from session to weekly when weekly remaining reaches the warning threshold", () => { + const bars = getTrayPrimaryBars({ + displayMode: "left", + weeklyWarningThresholdPercent: 30, + 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: 25, + limit: 100, + format: { kind: "percent" }, + }, + { + type: "progress", + label: "Weekly", + used: 75, + limit: 100, + format: { kind: "percent" }, + }, + ], + }, + loading: false, + error: null, + }, + }, + }) + + expect(bars).toEqual([{ id: "a", label: "Weekly", fraction: 0.25, warningSeverity: "warning" }]) + }) + + it("marks severe weekly depletion as critical", () => { + const bars = getTrayPrimaryBars({ + displayMode: "left", + weeklyWarningThresholdPercent: 30, + 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: 5, + limit: 100, + format: { kind: "percent" }, + }, + { + type: "progress", + label: "Weekly", + used: 90, + limit: 100, + format: { kind: "percent" }, + }, + ], + }, + loading: false, + error: null, + }, + }, + }) + + expect(bars).toEqual([{ id: "a", label: "Weekly", fraction: 0.1, warningSeverity: "critical" }]) }) it("skips plugins with empty primaryCandidates", () => { @@ -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..80376ba1 100644 --- a/src/lib/tray-primary-progress.ts +++ b/src/lib/tray-primary-progress.ts @@ -1,6 +1,11 @@ import type { PluginMeta, PluginOutput } from "@/lib/plugin-types" -import type { PluginSettings } from "@/lib/settings" -import { DEFAULT_DISPLAY_MODE, type DisplayMode } from "@/lib/settings" +import type { PluginSettings, WeeklyWarningThresholdPercent } from "@/lib/settings" +import { + DEFAULT_DISPLAY_MODE, + DEFAULT_WEEKLY_WARNING_THRESHOLD_PERCENT, + type DisplayMode, +} from "@/lib/settings" +import { selectTrayPrimaryMetric, type TrayAlertSeverity } from "@/lib/tray-alert" import { clamp01 } from "@/lib/utils" type PluginState = { @@ -11,16 +16,9 @@ type PluginState = { export type TrayPrimaryBar = { id: string + label?: string fraction?: number -} - -type ProgressLine = Extract< - PluginOutput["lines"][number], - { type: "progress"; label: string; used: number; limit: number } -> - -function isProgressLine(line: PluginOutput["lines"][number]): line is ProgressLine { - return line.type === "progress" + warningSeverity: TrayAlertSeverity } export function getTrayPrimaryBars(args: { @@ -30,6 +28,7 @@ export function getTrayPrimaryBars(args: { maxBars?: number displayMode?: DisplayMode pluginId?: string + weeklyWarningThresholdPercent?: WeeklyWarningThresholdPercent }): TrayPrimaryBar[] { const { pluginsMeta, @@ -38,6 +37,7 @@ export function getTrayPrimaryBars(args: { maxBars = 4, displayMode = DEFAULT_DISPLAY_MODE, pluginId, + weeklyWarningThresholdPercent = DEFAULT_WEEKLY_WARNING_THRESHOLD_PERCENT, } = args if (!pluginSettings) return [] @@ -59,31 +59,29 @@ export function getTrayPrimaryBars(args: { const state = pluginStates[id] const data = state?.data ?? null + const { line: primaryLine, warningSeverity } = selectTrayPrimaryMetric({ + meta, + data, + weeklyWarningThresholdPercent, + }) + let fraction: number | undefined - if (data) { - // Find first candidate that exists in runtime data - const primaryLabel = meta.primaryCandidates.find((label) => - data.lines.some((line) => isProgressLine(line) && line.label === label) - ) - if (primaryLabel) { - const primaryLine = data.lines.find( - (line): line is ProgressLine => - isProgressLine(line) && line.label === primaryLabel - ) - if (primaryLine && primaryLine.limit > 0) { - const shownAmount = - displayMode === "used" - ? primaryLine.used - : primaryLine.limit - primaryLine.used - fraction = clamp01(shownAmount / primaryLine.limit) - } - } + if (primaryLine && primaryLine.limit > 0) { + const shownAmount = + displayMode === "used" + ? primaryLine.used + : primaryLine.limit - primaryLine.used + fraction = clamp01(shownAmount / primaryLine.limit) } - out.push({ id, fraction }) + out.push({ + id, + label: primaryLine?.label, + fraction, + warningSeverity, + }) if (out.length >= maxBars) break } return out } - diff --git a/src/lib/tray-tooltip.test.ts b/src/lib/tray-tooltip.test.ts index 596d45c9..a9655ce4 100644 --- a/src/lib/tray-tooltip.test.ts +++ b/src/lib/tray-tooltip.test.ts @@ -39,8 +39,8 @@ describe("tray-tooltip", () => { it("should list enabled plugins with percentages", () => { const bars: TrayPrimaryBar[] = [ - { id: "p1", fraction: 0.45 }, - { id: "p2", fraction: 0.12 }, + { id: "p1", fraction: 0.45, warningSeverity: "none" }, + { id: "p2", fraction: 0.12, warningSeverity: "none" }, ] const tooltip = formatTrayTooltip(bars, mockMeta) expect(tooltip).toBe("OpenUsage\nPlugin 1: 45%\nPlugin 2: 12%") @@ -48,8 +48,8 @@ describe("tray-tooltip", () => { it("should handle missing plugin metadata gracefully", () => { const bars: TrayPrimaryBar[] = [ - { id: "p1", fraction: 0.45 }, - { id: "unknown", fraction: 0.5 }, + { id: "p1", fraction: 0.45, warningSeverity: "none" }, + { id: "unknown", fraction: 0.5, warningSeverity: "none" }, ] const tooltip = formatTrayTooltip(bars, mockMeta) expect(tooltip).toBe("OpenUsage\nPlugin 1: 45%") @@ -57,10 +57,18 @@ describe("tray-tooltip", () => { it("should show --% for missing fractions", () => { const bars: TrayPrimaryBar[] = [ - { id: "p1", fraction: undefined }, + { id: "p1", fraction: undefined, warningSeverity: "none" }, ] const tooltip = formatTrayTooltip(bars, mockMeta) expect(tooltip).toBe("OpenUsage\nPlugin 1: --%") }) + + it("prefixes alerted providers with an indicator", () => { + const bars: TrayPrimaryBar[] = [ + { id: "p1", fraction: 0.18, warningSeverity: "warning" }, + ] + const tooltip = formatTrayTooltip(bars, mockMeta) + expect(tooltip).toBe("OpenUsage\n! Plugin 1: 18%") + }) }) }) diff --git a/src/lib/tray-tooltip.ts b/src/lib/tray-tooltip.ts index 78fef9ac..ae3bb8d3 100644 --- a/src/lib/tray-tooltip.ts +++ b/src/lib/tray-tooltip.ts @@ -23,7 +23,8 @@ export function formatTrayTooltip(bars: TrayPrimaryBar[], pluginsMeta: PluginMet const meta = metaById.get(bar.id) if (meta) { const percent = formatTrayPercentText(bar.fraction) - lines.push(`${meta.name}: ${percent}`) + const prefix = bar.warningSeverity === "none" ? "" : "! " + lines.push(`${prefix}${meta.name}: ${percent}`) } } return lines.join("\n") diff --git a/src/pages/settings.test.tsx b/src/pages/settings.test.tsx index 9139f79e..04faf902 100644 --- a/src/pages/settings.test.tsx +++ b/src/pages/settings.test.tsx @@ -57,11 +57,14 @@ const defaultProps = { onResetTimerDisplayModeChange: vi.fn(), menubarIconStyle: "provider" as const, onMenubarIconStyleChange: vi.fn(), + weeklyWarningThresholdPercent: 30 as const, + onWeeklyWarningThresholdPercentChange: vi.fn(), traySettingsPreview: { bars: [{ id: "a", fraction: 0.7 }], providerBars: [{ id: "a", fraction: 0.7 }], providerIconUrl: "icon-a", providerPercentText: "70%", + providerAlertSeverity: "none" as const, }, globalShortcut: null, onGlobalShortcutChange: vi.fn(), @@ -197,6 +200,18 @@ describe("SettingsPage", () => { expect(screen.getByText("What shows in the menu bar")).toBeInTheDocument() }) + it("updates weekly warning threshold", async () => { + const onWeeklyWarningThresholdPercentChange = vi.fn() + render( + + ) + await userEvent.click(screen.getByRole("radio", { name: "40%" })) + expect(onWeeklyWarningThresholdPercentChange).toHaveBeenCalledWith(40) + }) + it("clicking Bars triggers onMenubarIconStyleChange(\"bars\")", async () => { const onMenubarIconStyleChange = vi.fn() render( diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 1f7abada..df298b99 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -26,12 +26,14 @@ import { MENUBAR_ICON_STYLE_OPTIONS, RESET_TIMER_DISPLAY_OPTIONS, THEME_OPTIONS, + WEEKLY_WARNING_THRESHOLD_PERCENT_OPTIONS, type AutoUpdateIntervalMinutes, type DisplayMode, type GlobalShortcut, type MenubarIconStyle, type ResetTimerDisplayMode, type ThemeMode, + type WeeklyWarningThresholdPercent, } from "@/lib/settings"; import type { TraySettingsPreview } from "@/hooks/app/use-tray-icon"; import { cn } from "@/lib/utils"; @@ -265,6 +267,8 @@ interface SettingsPageProps { onResetTimerDisplayModeChange: (value: ResetTimerDisplayMode) => void; menubarIconStyle: MenubarIconStyle; onMenubarIconStyleChange: (value: MenubarIconStyle) => void; + weeklyWarningThresholdPercent: WeeklyWarningThresholdPercent; + onWeeklyWarningThresholdPercentChange: (value: WeeklyWarningThresholdPercent) => void; traySettingsPreview: TraySettingsPreview; globalShortcut: GlobalShortcut; onGlobalShortcutChange: (value: GlobalShortcut) => void; @@ -286,6 +290,8 @@ export function SettingsPage({ onResetTimerDisplayModeChange, menubarIconStyle, onMenubarIconStyleChange, + weeklyWarningThresholdPercent, + onWeeklyWarningThresholdPercentChange, traySettingsPreview, globalShortcut, onGlobalShortcutChange, @@ -439,6 +445,33 @@ export function SettingsPage({ +
+

Weekly Warning

+

+ Switch the menubar to weekly budget when remaining drops this low +

+
+
+ {WEEKLY_WARNING_THRESHOLD_PERCENT_OPTIONS.map((option) => { + const isActive = option.value === weeklyWarningThresholdPercent; + return ( + + ); + })} +
+
+

App Theme

diff --git a/src/stores/app-preferences-store.ts b/src/stores/app-preferences-store.ts index 98ced539..fd1202d7 100644 --- a/src/stores/app-preferences-store.ts +++ b/src/stores/app-preferences-store.ts @@ -7,12 +7,14 @@ import { DEFAULT_RESET_TIMER_DISPLAY_MODE, DEFAULT_START_ON_LOGIN, DEFAULT_THEME_MODE, + DEFAULT_WEEKLY_WARNING_THRESHOLD_PERCENT, type AutoUpdateIntervalMinutes, type DisplayMode, type GlobalShortcut, type MenubarIconStyle, type ResetTimerDisplayMode, type ThemeMode, + type WeeklyWarningThresholdPercent, } from "@/lib/settings" type AppPreferencesStore = { @@ -23,6 +25,7 @@ type AppPreferencesStore = { globalShortcut: GlobalShortcut startOnLogin: boolean menubarIconStyle: MenubarIconStyle + weeklyWarningThresholdPercent: WeeklyWarningThresholdPercent setAutoUpdateInterval: (value: AutoUpdateIntervalMinutes) => void setThemeMode: (value: ThemeMode) => void setDisplayMode: (value: DisplayMode) => void @@ -30,6 +33,7 @@ type AppPreferencesStore = { setGlobalShortcut: (value: GlobalShortcut) => void setStartOnLogin: (value: boolean) => void setMenubarIconStyle: (value: MenubarIconStyle) => void + setWeeklyWarningThresholdPercent: (value: WeeklyWarningThresholdPercent) => void resetState: () => void } @@ -41,6 +45,7 @@ const initialState = { globalShortcut: DEFAULT_GLOBAL_SHORTCUT, startOnLogin: DEFAULT_START_ON_LOGIN, menubarIconStyle: DEFAULT_MENUBAR_ICON_STYLE, + weeklyWarningThresholdPercent: DEFAULT_WEEKLY_WARNING_THRESHOLD_PERCENT, } export const useAppPreferencesStore = create((set) => ({ @@ -52,5 +57,6 @@ export const useAppPreferencesStore = create((set) => ({ setGlobalShortcut: (value) => set({ globalShortcut: value }), setStartOnLogin: (value) => set({ startOnLogin: value }), setMenubarIconStyle: (value) => set({ menubarIconStyle: value }), + setWeeklyWarningThresholdPercent: (value) => set({ weeklyWarningThresholdPercent: value }), resetState: () => set(initialState), }))