diff --git a/docs/plugins/schema.md b/docs/plugins/schema.md index 2b33f22f..8211b7f4 100644 --- a/docs/plugins/schema.md +++ b/docs/plugins/schema.md @@ -95,19 +95,20 @@ loading skeletons instantly while probes execute asynchronously. | `type` | string | Yes | One of: `text`, `progress`, `badge`, `barChart` | | `label` | string | Yes | Static label shown in the UI for this line | | `scope` | string | Yes | `"overview"` or `"detail"` - where line appears | -| `primary` | boolean | No | If `true`, this progress line appears in tray icon | +| `primaryOrder` | number | No | Lower number = higher priority; orders this progress line among the tray-icon candidates (see below) | +| `period` | string | No | `"weekly"` marks this line as the provider's weekly metric (see below) | - `"overview"` - shown on both Overview tab and plugin detail pages - `"detail"` - shown only on plugin detail pages ### Primary Progress (Tray Icon) -Plugins can optionally mark one progress line as `primary: true`. This progress metric will be displayed as a horizontal bar in the system tray icon, allowing users to see usage at a glance without opening the app. +Progress lines opt into the system tray icon by setting `primaryOrder` (a number). Lines are sorted by `primaryOrder` into an ordered list of candidates, and the tray shows the **first candidate that has runtime data** — falling back to the next when an earlier one is absent. This lets a provider prefer a short-window metric but degrade gracefully when it isn't reported. Rules: -- Only `type: "progress"` lines can be primary (the flag is ignored on other types) -- Only the **first** `primary: true` line is used (subsequent ones are ignored) -- Up to 4 enabled plugins with primary progress are shown in the tray (in plugin order) +- Only `type: "progress"` lines are candidates (`primaryOrder` is ignored on other types) +- Lower `primaryOrder` wins; the frontend walks the ordered list and uses the first one present in live data +- Up to 4 enabled plugins are shown in the tray (in plugin order) - If no data is available yet, the bar shows as a track without fill Example: @@ -116,13 +117,28 @@ Example: { "lines": [ { "type": "badge", "label": "Plan", "scope": "overview" }, - { "type": "progress", "label": "Plan usage", "scope": "overview", "primary": true }, - { "type": "progress", "label": "Extra", "scope": "detail" }, + { "type": "progress", "label": "Plan usage", "scope": "overview", "primaryOrder": 1 }, + { "type": "progress", "label": "Overage", "scope": "overview", "primaryOrder": 2 }, { "type": "text", "label": "Resets", "scope": "detail" } ] } ``` +### Weekly Metric (Menubar) + +A provider can mark one progress line with `"period": "weekly"`. When the user sets the menubar metric to **Weekly** (Settings → Menubar Icon), the tray icon and tooltip show this line instead of the provider's primary metric. + +It is an **override of the primary metric**, not a standalone mode: the provider must still define a primary (`primaryOrder`) line — a provider with *only* a weekly line will not appear in the menubar. Providers without a weekly line keep showing their primary. `period` only recognizes `"weekly"` (other values are ignored), and only the first `"period": "weekly"` line is used. + +```json +{ + "lines": [ + { "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 }, + { "type": "progress", "label": "Weekly", "scope": "overview", "period": "weekly" } + ] +} +``` + ## Entry Point Structure Plugins must register themselves on the global object: diff --git a/plugins/claude/plugin.json b/plugins/claude/plugin.json index 5c6f9ef7..7a8f93a4 100644 --- a/plugins/claude/plugin.json +++ b/plugins/claude/plugin.json @@ -12,7 +12,7 @@ ], "lines": [ { "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 }, - { "type": "progress", "label": "Weekly", "scope": "overview", "primaryOrder": 2 }, + { "type": "progress", "label": "Weekly", "scope": "overview", "period": "weekly", "primaryOrder": 2 }, { "type": "progress", "label": "Sonnet", "scope": "detail" }, { "type": "progress", "label": "Claude Design", "scope": "detail" }, { "type": "progress", "label": "Extra usage spent", "scope": "overview", "primaryOrder": 3 }, diff --git a/plugins/codex/plugin.json b/plugins/codex/plugin.json index 95aa7b4f..8695b87c 100644 --- a/plugins/codex/plugin.json +++ b/plugins/codex/plugin.json @@ -12,7 +12,7 @@ ], "lines": [ { "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 }, - { "type": "progress", "label": "Weekly", "scope": "overview" }, + { "type": "progress", "label": "Weekly", "scope": "overview", "period": "weekly" }, { "type": "progress", "label": "Spark", "scope": "detail" }, { "type": "progress", "label": "Spark Weekly", "scope": "detail" }, { "type": "progress", "label": "Reviews", "scope": "detail" }, diff --git a/plugins/devin/plugin.json b/plugins/devin/plugin.json index 1241dd64..ba98d9dc 100644 --- a/plugins/devin/plugin.json +++ b/plugins/devin/plugin.json @@ -7,7 +7,7 @@ "icon": "icon.svg", "brandColor": "#000000", "lines": [ - { "type": "progress", "label": "Weekly quota", "scope": "overview", "primaryOrder": 1 }, + { "type": "progress", "label": "Weekly quota", "scope": "overview", "period": "weekly", "primaryOrder": 1 }, { "type": "progress", "label": "Daily quota", "scope": "overview" }, { "type": "text", "label": "Extra usage balance", "scope": "detail" } ] diff --git a/plugins/kimi/plugin.json b/plugins/kimi/plugin.json index 58ba38d2..db41dcf3 100644 --- a/plugins/kimi/plugin.json +++ b/plugins/kimi/plugin.json @@ -8,6 +8,6 @@ "brandColor": "#000000", "lines": [ { "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 }, - { "type": "progress", "label": "Weekly", "scope": "overview", "primaryOrder": 2 } + { "type": "progress", "label": "Weekly", "scope": "overview", "period": "weekly", "primaryOrder": 2 } ] } diff --git a/plugins/opencode-go/plugin.json b/plugins/opencode-go/plugin.json index 1b010135..87e4eeb5 100644 --- a/plugins/opencode-go/plugin.json +++ b/plugins/opencode-go/plugin.json @@ -26,7 +26,8 @@ { "type": "progress", "label": "Weekly", - "scope": "overview" + "scope": "overview", + "period": "weekly" }, { "type": "progress", diff --git a/plugins/opencode-go/plugin.test.js b/plugins/opencode-go/plugin.test.js index d7cdf6f1..1a10261c 100644 --- a/plugins/opencode-go/plugin.test.js +++ b/plugins/opencode-go/plugin.test.js @@ -82,7 +82,7 @@ describe("opencode-go plugin", () => { ]); expect(manifest.lines).toEqual([ { type: "progress", label: "Session", scope: "overview", primaryOrder: 1 }, - { type: "progress", label: "Weekly", scope: "overview" }, + { type: "progress", label: "Weekly", scope: "overview", period: "weekly" }, { type: "progress", label: "Monthly", scope: "detail" }, ]); }); diff --git a/plugins/zai/plugin.json b/plugins/zai/plugin.json index b60b0cbb..9b167551 100644 --- a/plugins/zai/plugin.json +++ b/plugins/zai/plugin.json @@ -8,7 +8,7 @@ "brandColor": "#2D2D2D", "lines": [ { "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 }, - { "type": "progress", "label": "Weekly", "scope": "overview" }, + { "type": "progress", "label": "Weekly", "scope": "overview", "period": "weekly" }, { "type": "progress", "label": "Web Searches", "scope": "overview" } ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bf55ba43..d21f4241 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -156,6 +156,9 @@ pub struct PluginMeta { /// Ordered list of primary metric candidates (sorted by primaryOrder). /// Frontend picks the first one that exists in runtime data. pub primary_candidates: Vec, + /// Label of the progress line marked `"period": "weekly"`, if any. + /// Drives the menubar weekly-metric preference. + pub weekly_candidate: Option, } #[derive(Debug, Clone, Serialize)] @@ -465,6 +468,11 @@ fn list_plugins(state: tauri::State<'_, Mutex>) -> Vec { let primary_candidates: Vec = candidates.iter().map(|line| line.label.clone()).collect(); + // The weekly metric is the progress line declared `"period": "weekly"`. + let weekly_candidate: Option = + plugin_engine::manifest::weekly_candidate(&plugin.manifest.lines) + .map(str::to_string); + PluginMeta { id: plugin.manifest.id, name: plugin.manifest.name, @@ -490,6 +498,7 @@ fn list_plugins(state: tauri::State<'_, Mutex>) -> Vec { }) .collect(), primary_candidates, + weekly_candidate, } }) .collect() diff --git a/src-tauri/src/plugin_engine/manifest.rs b/src-tauri/src/plugin_engine/manifest.rs index e363b51e..339afe69 100644 --- a/src-tauri/src/plugin_engine/manifest.rs +++ b/src-tauri/src/plugin_engine/manifest.rs @@ -12,6 +12,9 @@ pub struct ManifestLine { /// Lower number = higher priority for primary metric selection. /// Only progress lines with primary_order are candidates. pub primary_order: Option, + /// Marks this line as the provider's recurring-period metric for the + /// menubar metric preference. Currently only "weekly" is recognized. + pub period: Option, } #[derive(Debug, Clone, Deserialize)] @@ -69,6 +72,15 @@ pub fn load_plugins_from_dir(plugins_dir: &std::path::Path) -> Vec plugins } +/// Label of the progress line marked `"period": "weekly"`, if any. +/// Drives the menubar weekly-metric preference; first match wins. +pub fn weekly_candidate(lines: &[ManifestLine]) -> Option<&str> { + lines + .iter() + .find(|line| line.line_type == "progress" && line.period.as_deref() == Some("weekly")) + .map(|line| line.label.as_str()) +} + fn load_single_plugin( plugin_dir: &std::path::Path, ) -> Result> { @@ -77,7 +89,8 @@ fn load_single_plugin( let mut manifest: PluginManifest = serde_json::from_str(&manifest_text)?; manifest.links = sanitize_plugin_links(&manifest.id, std::mem::take(&mut manifest.links)); - // Validate primary_order: only progress lines can have it + // Validate primary_order / period: only progress lines can carry them, + // and period currently only recognizes "weekly". for line in manifest.lines.iter() { if line.primary_order.is_some() && line.line_type != "progress" { log::warn!( @@ -87,6 +100,23 @@ fn load_single_plugin( line.line_type ); } + if let Some(period) = line.period.as_deref() { + if line.line_type != "progress" { + log::warn!( + "plugin {} line '{}' has period but type is '{}'; will be ignored", + manifest.id, + line.label, + line.line_type + ); + } else if period != "weekly" { + log::warn!( + "plugin {} line '{}' has unsupported period '{}'; only \"weekly\" is recognized", + manifest.id, + line.label, + period + ); + } + } } if manifest.entry.trim().is_empty() { @@ -240,6 +270,102 @@ mod tests { assert_eq!(labels, vec!["First", "Second", "Third"]); } + #[test] + fn period_parsed_and_weekly_candidate_resolved() { + let manifest = parse_manifest( + r#" + { + "schemaVersion": 1, + "id": "x", + "name": "X", + "version": "0.0.1", + "entry": "plugin.js", + "icon": "icon.svg", + "brandColor": null, + "lines": [ + { "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 }, + { "type": "progress", "label": "Weekly", "scope": "overview", "period": "weekly" } + ] + } + "#, + ); + + assert!(manifest.lines[0].period.is_none()); + assert_eq!(manifest.lines[1].period.as_deref(), Some("weekly")); + + // Exercise the shipped resolver used by list_plugins. + assert_eq!(weekly_candidate(&manifest.lines), Some("Weekly")); + } + + #[test] + fn weekly_candidate_absent_when_no_period() { + let manifest = parse_manifest( + r#" + { + "schemaVersion": 1, + "id": "x", + "name": "X", + "version": "0.0.1", + "entry": "plugin.js", + "icon": "icon.svg", + "brandColor": null, + "lines": [ + { "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 } + ] + } + "#, + ); + + assert_eq!(weekly_candidate(&manifest.lines), None); + } + + #[test] + fn weekly_candidate_first_match_wins() { + // Precedence is intentionally first-match; lock it in so it can't drift silently. + let manifest = parse_manifest( + r#" + { + "schemaVersion": 1, + "id": "x", + "name": "X", + "version": "0.0.1", + "entry": "plugin.js", + "icon": "icon.svg", + "brandColor": null, + "lines": [ + { "type": "progress", "label": "Weekly A", "scope": "overview", "period": "weekly" }, + { "type": "progress", "label": "Weekly B", "scope": "overview", "period": "weekly" } + ] + } + "#, + ); + + assert_eq!(weekly_candidate(&manifest.lines), Some("Weekly A")); + } + + #[test] + fn weekly_candidate_ignores_unsupported_period() { + // A typo'd period (e.g. "week") is not recognized; the provider keeps its primary metric. + let manifest = parse_manifest( + r#" + { + "schemaVersion": 1, + "id": "x", + "name": "X", + "version": "0.0.1", + "entry": "plugin.js", + "icon": "icon.svg", + "brandColor": null, + "lines": [ + { "type": "progress", "label": "Weekly", "scope": "overview", "period": "week" } + ] + } + "#, + ); + + assert_eq!(weekly_candidate(&manifest.lines), None); + } + #[test] fn links_are_parsed_when_present() { let manifest = parse_manifest( diff --git a/src/App.test.tsx b/src/App.test.tsx index 4b1d2e35..12e43881 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(), + loadMenubarMetricMock: vi.fn(), + saveMenubarMetricMock: 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, + loadMenubarMetric: state.loadMenubarMetricMock, + saveMenubarMetric: state.saveMenubarMetricMock, 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.loadMenubarMetricMock.mockReset() + state.saveMenubarMetricMock.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.loadMenubarMetricMock.mockResolvedValue("default") + state.saveMenubarMetricMock.mockResolvedValue(undefined) state.migrateLegacyTraySettingsMock.mockResolvedValue(undefined) state.loadGlobalShortcutMock.mockResolvedValue(null) state.saveGlobalShortcutMock.mockResolvedValue(undefined) @@ -712,6 +720,115 @@ describe("App", () => { }) }) + it("settings UI persists menubar metric change and re-renders the tray", async () => { + state.loadDisplayModeMock.mockResolvedValue("used") + state.invokeMock.mockImplementation(async (cmd: string) => { + if (cmd === "list_plugins") { + return [ + { + id: "a", + name: "Alpha", + iconUrl: "icon-a", + primaryCandidates: ["Session"], + weeklyCandidate: "Weekly", + lines: [ + { type: "progress", label: "Session", scope: "overview" }, + { type: "progress", label: "Weekly", scope: "overview", period: "weekly" }, + ], + }, + ] + } + 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: 20, limit: 100, format: { kind: "percent" } }, + { type: "progress", label: "Weekly", used: 60, limit: 100, format: { kind: "percent" } }, + ], + }) + + const settingsButtons = await screen.findAllByRole("button", { name: "Settings" }) + await userEvent.click(settingsButtons[0]) + + await userEvent.click(await screen.findByRole("radio", { name: "Weekly" })) + expect(state.saveMenubarMetricMock).toHaveBeenCalledWith("weekly") + + await waitFor(() => { + const latestCall = state.renderTrayBarsIconMock.mock.calls.at(-1)?.[0] + expect(latestCall).toBeDefined() + expect(latestCall!.bars?.[0]?.fraction).toBe(0.6) + }) + }) + + it("sends the mixed-tagged weekly tooltip to the tray", async () => { + state.loadDisplayModeMock.mockResolvedValue("used") + state.invokeMock.mockImplementation(async (cmd: string) => { + if (cmd === "list_plugins") { + return [ + { + id: "claude", + name: "Claude", + iconUrl: "icon-claude", + primaryCandidates: ["Session"], + weeklyCandidate: "Weekly", + lines: [ + { type: "progress", label: "Session", scope: "overview" }, + { type: "progress", label: "Weekly", scope: "overview" }, + ], + }, + { + id: "cursor", + name: "Cursor", + iconUrl: "icon-cursor", + primaryCandidates: ["Credits"], + lines: [{ type: "progress", label: "Credits", scope: "overview" }], + }, + ] + } + return null + }) + state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["claude", "cursor"], disabled: [] }) + + render() + await waitFor(() => expect(state.startBatchMock).toHaveBeenCalled()) + + state.probeHandlers?.onResult({ + providerId: "claude", + displayName: "Claude", + iconUrl: "icon-claude", + lines: [ + { type: "progress", label: "Session", used: 20, limit: 100, format: { kind: "percent" } }, + { type: "progress", label: "Weekly", used: 42, limit: 100, format: { kind: "percent" } }, + ], + }) + state.probeHandlers?.onResult({ + providerId: "cursor", + displayName: "Cursor", + iconUrl: "icon-cursor", + lines: [{ type: "progress", label: "Credits", used: 55, limit: 100, format: { kind: "percent" } }], + }) + + const settingsButtons = await screen.findAllByRole("button", { name: "Settings" }) + await userEvent.click(settingsButtons[0]) + await userEvent.click(await screen.findByRole("radio", { name: "Weekly" })) + + // Cursor has no weekly line -> falls back to its primary, so the list is mixed + // and every line gets a metric tag. + await waitFor(() => + expect(state.traySetTooltipMock).toHaveBeenCalledWith( + "OpenUsage\nClaude: 42% · Weekly\nCursor: 55% · Credits" + ) + ) + }) + it("logs when saving display mode fails", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) state.saveDisplayModeMock.mockRejectedValueOnce(new Error("save display mode")) diff --git a/src/App.tsx b/src/App.tsx index f9c420ea..86fb8af5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,6 +53,8 @@ function App() { setDisplayMode, menubarIconStyle, setMenubarIconStyle, + menubarMetric, + setMenubarMetric, resetTimerDisplayMode, setResetTimerDisplayMode, setTimeFormatMode, @@ -68,6 +70,8 @@ function App() { setDisplayMode: state.setDisplayMode, menubarIconStyle: state.menubarIconStyle, setMenubarIconStyle: state.setMenubarIconStyle, + menubarMetric: state.menubarMetric, + setMenubarMetric: state.setMenubarMetric, resetTimerDisplayMode: state.resetTimerDisplayMode, setResetTimerDisplayMode: state.setResetTimerDisplayMode, setTimeFormatMode: state.setTimeFormatMode, @@ -102,6 +106,7 @@ function App() { pluginStates, displayMode, menubarIconStyle, + menubarMetric, activeView, }) @@ -118,6 +123,7 @@ function App() { setThemeMode, setDisplayMode, setMenubarIconStyle, + setMenubarMetric, setResetTimerDisplayMode, setTimeFormatMode, setGlobalShortcut, @@ -136,6 +142,7 @@ function App() { handleResetTimerDisplayModeToggle, handleTimeFormatModeChange, handleMenubarIconStyleChange, + handleMenubarMetricChange, } = useSettingsDisplayActions({ setThemeMode, setDisplayMode, @@ -143,6 +150,7 @@ function App() { setResetTimerDisplayMode, setTimeFormatMode, setMenubarIconStyle, + setMenubarMetric, scheduleTrayIconUpdate, }) @@ -251,6 +259,7 @@ function App() { onResetTimerDisplayModeToggle: handleResetTimerDisplayModeToggle, onTimeFormatModeChange: handleTimeFormatModeChange, onMenubarIconStyleChange: handleMenubarIconStyleChange, + onMenubarMetricChange: handleMenubarMetricChange, traySettingsPreview, onGlobalShortcutChange: handleGlobalShortcutChange, onStartOnLoginChange: handleStartOnLoginChange, diff --git a/src/components/app/app-content.tsx b/src/components/app/app-content.tsx index c6afaec2..1b283d1c 100644 --- a/src/components/app/app-content.tsx +++ b/src/components/app/app-content.tsx @@ -12,6 +12,7 @@ import type { DisplayMode, GlobalShortcut, MenubarIconStyle, + MenubarMetric, ResetTimerDisplayMode, ThemeMode, TimeFormatMode, @@ -34,6 +35,7 @@ export type AppContentActionProps = { onResetTimerDisplayModeToggle: () => void onTimeFormatModeChange: (mode: TimeFormatMode) => void onMenubarIconStyleChange: (value: MenubarIconStyle) => void + onMenubarMetricChange: (value: MenubarMetric) => void traySettingsPreview: TraySettingsPreview onGlobalShortcutChange: (value: GlobalShortcut) => void onStartOnLoginChange: (value: boolean) => void @@ -55,6 +57,7 @@ export function AppContent({ onResetTimerDisplayModeToggle, onTimeFormatModeChange, onMenubarIconStyleChange, + onMenubarMetricChange, traySettingsPreview, onGlobalShortcutChange, onStartOnLoginChange, @@ -70,6 +73,7 @@ export function AppContent({ resetTimerDisplayMode, timeFormatMode, menubarIconStyle, + menubarMetric, autoUpdateInterval, globalShortcut, themeMode, @@ -80,6 +84,7 @@ export function AppContent({ resetTimerDisplayMode: state.resetTimerDisplayMode, timeFormatMode: state.timeFormatMode, menubarIconStyle: state.menubarIconStyle, + menubarMetric: state.menubarMetric, autoUpdateInterval: state.autoUpdateInterval, globalShortcut: state.globalShortcut, themeMode: state.themeMode, @@ -118,6 +123,8 @@ export function AppContent({ onTimeFormatModeChange={onTimeFormatModeChange} menubarIconStyle={menubarIconStyle} onMenubarIconStyleChange={onMenubarIconStyleChange} + menubarMetric={menubarMetric} + onMenubarMetricChange={onMenubarMetricChange} 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 c03bdcc3..d961849a 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, + loadMenubarMetricMock, loadPluginSettingsMock, loadResetTimerDisplayModeMock, loadStartOnLoginMock, @@ -34,6 +35,7 @@ const { loadDisplayModeMock: vi.fn(), loadGlobalShortcutMock: vi.fn(), loadMenubarIconStyleMock: vi.fn(), + loadMenubarMetricMock: vi.fn(), loadPluginSettingsMock: vi.fn(), loadResetTimerDisplayModeMock: vi.fn(), loadStartOnLoginMock: vi.fn(), @@ -62,6 +64,7 @@ vi.mock("@/lib/settings", () => ({ DEFAULT_DISPLAY_MODE: "left", DEFAULT_GLOBAL_SHORTCUT: null, DEFAULT_MENUBAR_ICON_STYLE: "provider", + DEFAULT_MENUBAR_METRIC: "default", DEFAULT_RESET_TIMER_DISPLAY_MODE: "relative", DEFAULT_START_ON_LOGIN: false, DEFAULT_THEME_MODE: "system", @@ -71,6 +74,7 @@ vi.mock("@/lib/settings", () => ({ loadDisplayMode: loadDisplayModeMock, loadGlobalShortcut: loadGlobalShortcutMock, loadMenubarIconStyle: loadMenubarIconStyleMock, + loadMenubarMetric: loadMenubarMetricMock, loadPluginSettings: loadPluginSettingsMock, loadResetTimerDisplayMode: loadResetTimerDisplayModeMock, loadStartOnLogin: loadStartOnLoginMock, @@ -96,6 +100,7 @@ function createArgs() { setGlobalShortcut: vi.fn(), setStartOnLogin: vi.fn(), setMenubarIconStyle: vi.fn(), + setMenubarMetric: vi.fn(), setLoadingForPlugins: vi.fn(), setErrorForPlugins: vi.fn(), startBatch: vi.fn().mockResolvedValue(undefined), @@ -115,6 +120,7 @@ describe("useSettingsBootstrap", () => { loadDisplayModeMock.mockReset() loadGlobalShortcutMock.mockReset() loadMenubarIconStyleMock.mockReset() + loadMenubarMetricMock.mockReset() loadPluginSettingsMock.mockReset() loadResetTimerDisplayModeMock.mockReset() loadStartOnLoginMock.mockReset() @@ -147,6 +153,7 @@ describe("useSettingsBootstrap", () => { loadTimeFormatModeMock.mockResolvedValue("auto") loadGlobalShortcutMock.mockResolvedValue("CommandOrControl+Shift+O") loadMenubarIconStyleMock.mockResolvedValue("provider") + loadMenubarMetricMock.mockResolvedValue("default") loadStartOnLoginMock.mockResolvedValue(true) migrateLegacyTraySettingsMock.mockResolvedValue(undefined) migrateWindsurfToDevinMock.mockImplementation((settings) => settings) @@ -183,6 +190,33 @@ describe("useSettingsBootstrap", () => { errorSpy.mockRestore() }) + it("applies the stored menubar metric", async () => { + loadMenubarMetricMock.mockResolvedValueOnce("weekly") + const args = createArgs() + + renderHook(() => useSettingsBootstrap(args)) + + await waitFor(() => { + expect(args.setMenubarMetric).toHaveBeenCalledWith("weekly") + }) + }) + + it("falls back to default menubar metric when loading fails", async () => { + const metricError = new Error("menubar metric unavailable") + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + loadMenubarMetricMock.mockRejectedValueOnce(metricError) + const args = createArgs() + + renderHook(() => useSettingsBootstrap(args)) + + await waitFor(() => { + expect(errorSpy).toHaveBeenCalledWith("Failed to load menubar metric:", metricError) + expect(args.setMenubarMetric).toHaveBeenCalledWith("default") + }) + + errorSpy.mockRestore() + }) + it("migrates windsurf settings before normalizing and saves the first-launch result", async () => { const args = createArgs() const storedSettings = { order: ["windsurf"], disabled: [] } diff --git a/src/hooks/app/use-settings-bootstrap.ts b/src/hooks/app/use-settings-bootstrap.ts index 37843cf4..577fcd0e 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_MENUBAR_METRIC, DEFAULT_RESET_TIMER_DISPLAY_MODE, DEFAULT_START_ON_LOGIN, DEFAULT_THEME_MODE, @@ -21,6 +22,7 @@ import { loadDisplayMode, loadGlobalShortcut, loadMenubarIconStyle, + loadMenubarMetric, migrateLegacyTraySettings, migrateWindsurfToDevin, loadPluginSettings, @@ -34,6 +36,7 @@ import { type DisplayMode, type GlobalShortcut, type MenubarIconStyle, + type MenubarMetric, type PluginSettings, type ResetTimerDisplayMode, type ThemeMode, @@ -51,6 +54,7 @@ type UseSettingsBootstrapArgs = { setGlobalShortcut: (value: GlobalShortcut) => void setStartOnLogin: (value: boolean) => void setMenubarIconStyle: (value: MenubarIconStyle) => void + setMenubarMetric: (value: MenubarMetric) => void setLoadingForPlugins: (ids: string[]) => void setErrorForPlugins: (ids: string[], error: string) => void startBatch: (pluginIds?: string[]) => Promise @@ -67,6 +71,7 @@ export function useSettingsBootstrap({ setGlobalShortcut, setStartOnLogin, setMenubarIconStyle, + setMenubarMetric, setLoadingForPlugins, setErrorForPlugins, startBatch, @@ -167,6 +172,13 @@ export function useSettingsBootstrap({ console.error("Failed to load menubar icon style:", error) } + let storedMenubarMetric = DEFAULT_MENUBAR_METRIC + try { + storedMenubarMetric = await loadMenubarMetric() + } catch (error) { + console.error("Failed to load menubar metric:", error) + } + if (isMounted) { setPluginSettings(normalized) setAutoUpdateInterval(storedInterval) @@ -177,6 +189,7 @@ export function useSettingsBootstrap({ setGlobalShortcut(storedGlobalShortcut) setStartOnLogin(storedStartOnLogin) setMenubarIconStyle(storedMenubarIconStyle) + setMenubarMetric(storedMenubarMetric) const enabledIds = getEnabledPluginIds(normalized) setLoadingForPlugins(enabledIds) @@ -207,6 +220,7 @@ export function useSettingsBootstrap({ setGlobalShortcut, setLoadingForPlugins, setMenubarIconStyle, + setMenubarMetric, migrateWindsurfToDevin, migrateLegacyTraySettings, setPluginSettings, diff --git a/src/hooks/app/use-settings-display-actions.test.ts b/src/hooks/app/use-settings-display-actions.test.ts index f8f4d214..6c65c435 100644 --- a/src/hooks/app/use-settings-display-actions.test.ts +++ b/src/hooks/app/use-settings-display-actions.test.ts @@ -3,12 +3,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest" const { saveDisplayModeMock, + saveMenubarMetricMock, saveResetTimerDisplayModeMock, saveThemeModeMock, saveTimeFormatModeMock, } = vi.hoisted(() => ({ saveThemeModeMock: vi.fn(), saveDisplayModeMock: vi.fn(), + saveMenubarMetricMock: vi.fn(), saveResetTimerDisplayModeMock: vi.fn(), saveTimeFormatModeMock: vi.fn(), })) @@ -16,6 +18,7 @@ const { vi.mock("@/lib/settings", () => ({ saveThemeMode: saveThemeModeMock, saveDisplayMode: saveDisplayModeMock, + saveMenubarMetric: saveMenubarMetricMock, saveResetTimerDisplayMode: saveResetTimerDisplayModeMock, saveTimeFormatMode: saveTimeFormatModeMock, })) @@ -26,10 +29,12 @@ describe("useSettingsDisplayActions", () => { beforeEach(() => { saveThemeModeMock.mockReset() saveDisplayModeMock.mockReset() + saveMenubarMetricMock.mockReset() saveResetTimerDisplayModeMock.mockReset() saveTimeFormatModeMock.mockReset() saveThemeModeMock.mockResolvedValue(undefined) saveDisplayModeMock.mockResolvedValue(undefined) + saveMenubarMetricMock.mockResolvedValue(undefined) saveResetTimerDisplayModeMock.mockResolvedValue(undefined) saveTimeFormatModeMock.mockResolvedValue(undefined) }) @@ -39,6 +44,7 @@ describe("useSettingsDisplayActions", () => { const setDisplayMode = vi.fn() const setResetTimerDisplayMode = vi.fn() const setTimeFormatMode = vi.fn() + const setMenubarMetric = vi.fn() const scheduleTrayIconUpdate = vi.fn() const { result } = renderHook(() => @@ -49,6 +55,7 @@ describe("useSettingsDisplayActions", () => { setResetTimerDisplayMode, setTimeFormatMode, setMenubarIconStyle: vi.fn(), + setMenubarMetric, scheduleTrayIconUpdate, }) ) @@ -58,18 +65,21 @@ describe("useSettingsDisplayActions", () => { result.current.handleDisplayModeChange("used") result.current.handleResetTimerDisplayModeChange("absolute") result.current.handleTimeFormatModeChange("24h") + result.current.handleMenubarMetricChange("weekly") }) expect(setThemeMode).toHaveBeenCalledWith("dark") expect(setDisplayMode).toHaveBeenCalledWith("used") expect(setResetTimerDisplayMode).toHaveBeenCalledWith("absolute") expect(setTimeFormatMode).toHaveBeenCalledWith("24h") + expect(setMenubarMetric).toHaveBeenCalledWith("weekly") expect(scheduleTrayIconUpdate).toHaveBeenCalledWith("settings", 0) expect(saveThemeModeMock).toHaveBeenCalledWith("dark") expect(saveDisplayModeMock).toHaveBeenCalledWith("used") expect(saveResetTimerDisplayModeMock).toHaveBeenCalledWith("absolute") expect(saveTimeFormatModeMock).toHaveBeenCalledWith("24h") + expect(saveMenubarMetricMock).toHaveBeenCalledWith("weekly") }) it("toggles reset timer mode in both directions", () => { @@ -84,6 +94,7 @@ describe("useSettingsDisplayActions", () => { setResetTimerDisplayMode, setTimeFormatMode: vi.fn(), setMenubarIconStyle: vi.fn(), + setMenubarMetric: vi.fn(), scheduleTrayIconUpdate: vi.fn(), }), { initialProps: { mode: "relative" as const } } @@ -121,6 +132,7 @@ describe("useSettingsDisplayActions", () => { setResetTimerDisplayMode: vi.fn(), setTimeFormatMode: vi.fn(), setMenubarIconStyle: vi.fn(), + setMenubarMetric: vi.fn(), scheduleTrayIconUpdate: vi.fn(), }) ) diff --git a/src/hooks/app/use-settings-display-actions.ts b/src/hooks/app/use-settings-display-actions.ts index e0998bc1..34d5a0ce 100644 --- a/src/hooks/app/use-settings-display-actions.ts +++ b/src/hooks/app/use-settings-display-actions.ts @@ -2,11 +2,13 @@ import { useCallback } from "react" import { saveDisplayMode, saveMenubarIconStyle, + saveMenubarMetric, saveResetTimerDisplayMode, saveThemeMode, saveTimeFormatMode, type DisplayMode, type MenubarIconStyle, + type MenubarMetric, type ResetTimerDisplayMode, type ThemeMode, type TimeFormatMode, @@ -21,6 +23,7 @@ type UseSettingsDisplayActionsArgs = { setResetTimerDisplayMode: (value: ResetTimerDisplayMode) => void setTimeFormatMode: (value: TimeFormatMode) => void setMenubarIconStyle: (value: MenubarIconStyle) => void + setMenubarMetric: (value: MenubarMetric) => void scheduleTrayIconUpdate: ScheduleTrayIconUpdate } @@ -31,6 +34,7 @@ export function useSettingsDisplayActions({ setResetTimerDisplayMode, setTimeFormatMode, setMenubarIconStyle, + setMenubarMetric, scheduleTrayIconUpdate, }: UseSettingsDisplayActionsArgs) { const handleThemeModeChange = useCallback((mode: ThemeMode) => { @@ -75,6 +79,14 @@ export function useSettingsDisplayActions({ }) }, [scheduleTrayIconUpdate, setMenubarIconStyle]) + const handleMenubarMetricChange = useCallback((metric: MenubarMetric) => { + setMenubarMetric(metric) + scheduleTrayIconUpdate("settings", 0) + void saveMenubarMetric(metric).catch((error) => { + console.error("Failed to save menubar metric:", error) + }) + }, [scheduleTrayIconUpdate, setMenubarMetric]) + return { handleThemeModeChange, handleDisplayModeChange, @@ -82,5 +94,6 @@ export function useSettingsDisplayActions({ handleResetTimerDisplayModeToggle, handleTimeFormatModeChange, handleMenubarIconStyleChange, + handleMenubarMetricChange, } } diff --git a/src/hooks/app/use-tray-icon.ts b/src/hooks/app/use-tray-icon.ts index a98acc8b..e877114b 100644 --- a/src/hooks/app/use-tray-icon.ts +++ b/src/hooks/app/use-tray-icon.ts @@ -2,7 +2,7 @@ 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, MenubarMetric, PluginSettings } from "@/lib/settings" import { getEnabledPluginIds } from "@/lib/settings" import { getTrayIconSizePx, renderTrayBarsIcon } from "@/lib/tray-bars-icon" import { getTrayPrimaryBars, type TrayPrimaryBar } from "@/lib/tray-primary-progress" @@ -17,6 +17,7 @@ type UseTrayIconArgs = { pluginStates: Record displayMode: DisplayMode menubarIconStyle: MenubarIconStyle + menubarMetric: MenubarMetric activeView: string } @@ -55,6 +56,7 @@ export function useTrayIcon({ pluginStates, displayMode, menubarIconStyle, + menubarMetric, 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 menubarMetricRef = useRef(menubarMetric) const activeViewRef = useRef(activeView) const lastTrayProviderIdRef = useRef(null) @@ -95,6 +98,10 @@ export function useTrayIcon({ menubarIconStyleRef.current = menubarIconStyle }, [menubarIconStyle]) + useEffect(() => { + menubarMetricRef.current = menubarMetric + }, [menubarMetric]) + useEffect(() => { activeViewRef.current = activeView }, [activeView]) @@ -185,6 +192,7 @@ export function useTrayIcon({ } const style = menubarIconStyleRef.current + const preferWeekly = menubarMetricRef.current === "weekly" const sizePx = getTrayIconSizePx(window.devicePixelRatio) const nextActiveView = activeViewRef.current const activeProviderId = @@ -208,6 +216,7 @@ export function useTrayIcon({ pluginStates: pluginStatesRef.current, maxBars: 4, displayMode: displayModeRef.current, + preferWeekly, }) const providerBars = trayProviderId @@ -218,6 +227,7 @@ export function useTrayIcon({ maxBars: 1, displayMode: displayModeRef.current, pluginId: trayProviderId, + preferWeekly, }) : [] @@ -242,8 +252,9 @@ export function useTrayIcon({ pluginStates: pluginStatesRef.current, maxBars: 20, // Show more in tooltip displayMode: displayModeRef.current, + preferWeekly, }) - const tooltip = formatTrayTooltip(tooltipBars, pluginsMetaRef.current) + const tooltip = formatTrayTooltip(tooltipBars, pluginsMetaRef.current, preferWeekly) const updateTooltip = () => setTrayTooltip(tooltip) if (style === "bars") { @@ -357,7 +368,7 @@ export function useTrayIcon({ useEffect(() => { if (!trayReady) return scheduleTrayIconUpdate("settings", 0) - }, [activeView, menubarIconStyle, scheduleTrayIconUpdate, trayReady]) + }, [activeView, menubarIconStyle, menubarMetric, scheduleTrayIconUpdate, trayReady]) useEffect(() => { return () => { diff --git a/src/lib/plugin-types.ts b/src/lib/plugin-types.ts index e01d8969..1642245d 100644 --- a/src/lib/plugin-types.ts +++ b/src/lib/plugin-types.ts @@ -52,6 +52,8 @@ export type PluginMeta = { links?: PluginLink[] /** Ordered list of primary metric candidates. Frontend picks first available. */ primaryCandidates: string[] + /** Label of the line marked `"period": "weekly"`, if the provider has one. */ + weeklyCandidate?: string } export type PluginDisplayState = { diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 91d69fdb..6997ebbc 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_MENUBAR_METRIC, DEFAULT_PLUGIN_SETTINGS, DEFAULT_RESET_TIMER_DISPLAY_MODE, DEFAULT_START_ON_LOGIN, @@ -15,6 +16,7 @@ import { loadDisplayMode, loadGlobalShortcut, loadMenubarIconStyle, + loadMenubarMetric, loadPluginSettings, loadResetTimerDisplayMode, loadStartOnLogin, @@ -27,6 +29,7 @@ import { saveDisplayMode, saveGlobalShortcut, saveMenubarIconStyle, + saveMenubarMetric, savePluginSettings, saveResetTimerDisplayMode, saveStartOnLogin, @@ -332,6 +335,25 @@ describe("settings", () => { await expect(loadMenubarIconStyle()).resolves.toBe(DEFAULT_MENUBAR_ICON_STYLE) }) + it("loads default menubar metric when missing", async () => { + await expect(loadMenubarMetric()).resolves.toBe(DEFAULT_MENUBAR_METRIC) + }) + + it("loads stored menubar metric", async () => { + storeState.set("menubarMetric", "weekly") + await expect(loadMenubarMetric()).resolves.toBe("weekly") + }) + + it("saves menubar metric", async () => { + await saveMenubarMetric("weekly") + await expect(loadMenubarMetric()).resolves.toBe("weekly") + }) + + it("falls back to default for invalid menubar metric", async () => { + storeState.set("menubarMetric", "invalid") + await expect(loadMenubarMetric()).resolves.toBe(DEFAULT_MENUBAR_METRIC) + }) + 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 6b0caedd..6a44fd20 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -22,6 +22,8 @@ export type TimeFormatMode = "auto" | "12h" | "24h"; export type MenubarIconStyle = "provider" | "bars" | "donut"; +export type MenubarMetric = "default" | "weekly"; + export type GlobalShortcut = string | null; const SETTINGS_STORE_PATH = "settings.json"; @@ -32,6 +34,7 @@ const DISPLAY_MODE_KEY = "displayMode"; const RESET_TIMER_DISPLAY_MODE_KEY = "resetTimerDisplayMode"; const TIME_FORMAT_MODE_KEY = "timeFormatMode"; const MENUBAR_ICON_STYLE_KEY = "menubarIconStyle"; +const MENUBAR_METRIC_KEY = "menubarMetric"; const LEGACY_TRAY_ICON_STYLE_KEY = "trayIconStyle"; const LEGACY_TRAY_SHOW_PERCENTAGE_KEY = "trayShowPercentage"; const GLOBAL_SHORTCUT_KEY = "globalShortcut"; @@ -43,6 +46,7 @@ export const DEFAULT_DISPLAY_MODE: DisplayMode = "left"; export const DEFAULT_RESET_TIMER_DISPLAY_MODE: ResetTimerDisplayMode = "relative"; export const DEFAULT_TIME_FORMAT_MODE: TimeFormatMode = "auto"; export const DEFAULT_MENUBAR_ICON_STYLE: MenubarIconStyle = "provider"; +export const DEFAULT_MENUBAR_METRIC: MenubarMetric = "default"; export const DEFAULT_GLOBAL_SHORTCUT: GlobalShortcut = null; export const DEFAULT_START_ON_LOGIN = false; @@ -52,6 +56,7 @@ const DISPLAY_MODES: DisplayMode[] = ["used", "left"]; const RESET_TIMER_DISPLAY_MODES: ResetTimerDisplayMode[] = ["relative", "absolute"]; const TIME_FORMAT_MODES: TimeFormatMode[] = ["auto", "12h", "24h"]; const MENUBAR_ICON_STYLES: MenubarIconStyle[] = ["provider", "donut", "bars"]; +const MENUBAR_METRICS: MenubarMetric[] = ["default", "weekly"]; export const MENUBAR_ICON_STYLE_OPTIONS: { value: MenubarIconStyle; label: string }[] = [ { value: "provider", label: "Plugin" }, @@ -59,6 +64,11 @@ export const MENUBAR_ICON_STYLE_OPTIONS: { value: MenubarIconStyle; label: strin { value: "bars", label: "Bars" }, ]; +export const MENUBAR_METRIC_OPTIONS: { value: MenubarMetric; label: string }[] = [ + { value: "default", label: "Default" }, + { value: "weekly", label: "Weekly" }, +]; + export const AUTO_UPDATE_OPTIONS: { value: AutoUpdateIntervalMinutes; label: string }[] = AUTO_UPDATE_INTERVALS.map((value) => ({ value, @@ -285,6 +295,21 @@ export async function saveMenubarIconStyle(style: MenubarIconStyle): Promise { + const stored = await store.get(MENUBAR_METRIC_KEY); + if (isMenubarMetric(stored)) return stored; + return DEFAULT_MENUBAR_METRIC; +} + +export async function saveMenubarMetric(metric: MenubarMetric): Promise { + await store.set(MENUBAR_METRIC_KEY, metric); + 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 45290eb2..fff8ba46 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", fraction: 0.75, label: "Session" }]) }) it("includes plugins with primary candidates even when no data (fraction undefined)", () => { @@ -127,7 +127,7 @@ describe("getTrayPrimaryBars", () => { }, }) - expect(bars).toEqual([{ id: "a", fraction: 1 }]) + expect(bars).toEqual([{ id: "a", fraction: 1, label: "Plan usage" }]) }) 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", fraction: undefined, label: "Plan usage" }]) }) it("respects displayMode=left", () => { @@ -200,7 +200,7 @@ describe("getTrayPrimaryBars", () => { }, }, }) - expect(bars).toEqual([{ id: "a", fraction: 0.75 }]) + expect(bars).toEqual([{ id: "a", fraction: 0.75, label: "Session" }]) }) 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", fraction: 0.5, label: "Plan usage" }]) }) it("uses first candidate when both are available", () => { @@ -283,7 +283,7 @@ 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", fraction: 0.2, label: "Credits" }]) }) it("skips plugins with empty primaryCandidates", () => { @@ -353,7 +353,7 @@ describe("getTrayPrimaryBars", () => { format: { kind: "dollars" }, }, ]) - ).toEqual([{ id: "claude", fraction: 0.3 }]) + ).toEqual([{ id: "claude", fraction: 0.3, label: "Extra usage spent" }]) // Case 2: Weekly is available (but Session is not) expect( @@ -373,7 +373,7 @@ describe("getTrayPrimaryBars", () => { format: { kind: "dollars" }, }, ]) - ).toEqual([{ id: "claude", fraction: 0.4 }]) + ).toEqual([{ id: "claude", fraction: 0.4, label: "Weekly" }]) // Case 3: Session is available alongside Weekly and Extra usage spent (Session should win) expect( @@ -400,7 +400,115 @@ describe("getTrayPrimaryBars", () => { format: { kind: "dollars" }, }, ]) - ).toEqual([{ id: "claude", fraction: 0.5 }]) + ).toEqual([{ id: "claude", fraction: 0.5, label: "Session" }]) }) -}) + describe("weekly metric preference", () => { + const metaWithWeekly = { + id: "a", + name: "A", + iconUrl: "", + primaryCandidates: ["Session"], + weeklyCandidate: "Weekly", + lines: [], + } + + const sessionAndWeeklyData = { + a: { + data: { + providerId: "a", + displayName: "A", + iconUrl: "", + lines: [ + { + type: "progress" as const, + label: "Session", + used: 20, + limit: 100, + format: { kind: "percent" as const }, + }, + { + type: "progress" as const, + label: "Weekly", + used: 60, + limit: 100, + format: { kind: "percent" as const }, + }, + ], + }, + loading: false, + error: null, + }, + } + + it("prefers the weekly candidate when preferWeekly is set", () => { + const bars = getTrayPrimaryBars({ + displayMode: "used", + preferWeekly: true, + pluginsMeta: [metaWithWeekly], + pluginSettings: { order: ["a"], disabled: [] }, + pluginStates: sessionAndWeeklyData, + }) + expect(bars).toEqual([{ id: "a", fraction: 0.6, label: "Weekly", weekly: true }]) + }) + + it("ignores the weekly candidate when preferWeekly is false", () => { + const bars = getTrayPrimaryBars({ + displayMode: "used", + pluginsMeta: [metaWithWeekly], + pluginSettings: { order: ["a"], disabled: [] }, + pluginStates: sessionAndWeeklyData, + }) + expect(bars).toEqual([{ id: "a", fraction: 0.2, label: "Session" }]) + }) + + it("falls back to primary when the provider has no weekly candidate", () => { + const bars = getTrayPrimaryBars({ + displayMode: "used", + preferWeekly: true, + pluginsMeta: [ + { + id: "a", + name: "A", + iconUrl: "", + primaryCandidates: ["Session"], + lines: [], + }, + ], + pluginSettings: { order: ["a"], disabled: [] }, + pluginStates: sessionAndWeeklyData, + }) + expect(bars).toEqual([{ id: "a", fraction: 0.2, label: "Session" }]) + }) + + it("falls back to primary when the weekly candidate is absent from data", () => { + const bars = getTrayPrimaryBars({ + displayMode: "used", + preferWeekly: true, + pluginsMeta: [metaWithWeekly], + pluginSettings: { order: ["a"], disabled: [] }, + pluginStates: { + a: { + data: { + providerId: "a", + displayName: "A", + iconUrl: "", + lines: [ + { + type: "progress", + label: "Session", + used: 20, + limit: 100, + format: { kind: "percent" }, + }, + ], + }, + loading: false, + error: null, + }, + }, + }) + expect(bars).toEqual([{ id: "a", fraction: 0.2, label: "Session" }]) + }) + }) +}) diff --git a/src/lib/tray-primary-progress.ts b/src/lib/tray-primary-progress.ts index a139e29d..7ebd5087 100644 --- a/src/lib/tray-primary-progress.ts +++ b/src/lib/tray-primary-progress.ts @@ -12,6 +12,10 @@ type PluginState = { export type TrayPrimaryBar = { id: string fraction?: number + /** Label of the metric line that produced this bar (when data is available). */ + label?: string + /** True when the value came from the provider's declared weekly line. */ + weekly?: boolean } type ProgressLine = Extract< @@ -30,6 +34,7 @@ export function getTrayPrimaryBars(args: { maxBars?: number displayMode?: DisplayMode pluginId?: string + preferWeekly?: boolean }): TrayPrimaryBar[] { const { pluginsMeta, @@ -38,6 +43,7 @@ export function getTrayPrimaryBars(args: { maxBars = 4, displayMode = DEFAULT_DISPLAY_MODE, pluginId, + preferWeekly = false, } = args if (!pluginSettings) return [] @@ -53,34 +59,50 @@ export function getTrayPrimaryBars(args: { const meta = metaById.get(id) if (!meta) continue - // Skip if no primary candidates defined + // Skip plugins with no primary metric. Weekly mode is an override of the + // primary (see preferWeekly below), not a standalone mode — so a provider + // must define primaryCandidates to appear in the menubar; a weekly-only + // provider is intentionally skipped. if (!meta.primaryCandidates || meta.primaryCandidates.length === 0) continue const state = pluginStates[id] const data = state?.data ?? null let fraction: number | undefined + let label: string | undefined + let weekly: true | 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( + // Prefer the declared weekly line when requested and present in data. + const weeklyLabel = preferWeekly ? meta.weeklyCandidate : undefined + const usesWeekly = + weeklyLabel !== undefined && + data.lines.some((line) => isProgressLine(line) && line.label === weeklyLabel) + + // Otherwise fall back to the first primary candidate that exists in data. + const metricLabel = usesWeekly + ? weeklyLabel + : meta.primaryCandidates.find((candidate) => + data.lines.some((line) => isProgressLine(line) && line.label === candidate) + ) + + if (metricLabel) { + label = metricLabel + weekly = usesWeekly || undefined + const metricLine = data.lines.find( (line): line is ProgressLine => - isProgressLine(line) && line.label === primaryLabel + isProgressLine(line) && line.label === metricLabel ) - if (primaryLine && primaryLine.limit > 0) { + if (metricLine && metricLine.limit > 0) { const shownAmount = displayMode === "used" - ? primaryLine.used - : primaryLine.limit - primaryLine.used - fraction = clamp01(shownAmount / primaryLine.limit) + ? metricLine.used + : metricLine.limit - metricLine.used + fraction = clamp01(shownAmount / metricLine.limit) } } } - out.push({ id, fraction }) + out.push({ id, fraction, label, weekly }) if (out.length >= maxBars) break } diff --git a/src/lib/tray-tooltip.test.ts b/src/lib/tray-tooltip.test.ts index 596d45c9..2c7b7593 100644 --- a/src/lib/tray-tooltip.test.ts +++ b/src/lib/tray-tooltip.test.ts @@ -62,5 +62,32 @@ describe("tray-tooltip", () => { const tooltip = formatTrayTooltip(bars, mockMeta) expect(tooltip).toBe("OpenUsage\nPlugin 1: --%") }) + + it("omits tags in weekly mode when every line is weekly", () => { + const bars: TrayPrimaryBar[] = [ + { id: "p1", fraction: 0.42, label: "Weekly", weekly: true }, + { id: "p2", fraction: 0.6, label: "Weekly", weekly: true }, + ] + const tooltip = formatTrayTooltip(bars, mockMeta, true) + expect(tooltip).toBe("OpenUsage\nPlugin 1: 42%\nPlugin 2: 60%") + }) + + it("tags every line in weekly mode when the list is mixed", () => { + const bars: TrayPrimaryBar[] = [ + { id: "p1", fraction: 0.42, label: "Weekly", weekly: true }, + { id: "p2", fraction: 0.3, label: "Premium" }, + ] + const tooltip = formatTrayTooltip(bars, mockMeta, true) + expect(tooltip).toBe("OpenUsage\nPlugin 1: 42% · Weekly\nPlugin 2: 30% · Premium") + }) + + it("does not tag lines when weekly mode is off", () => { + const bars: TrayPrimaryBar[] = [ + { id: "p1", fraction: 0.42, label: "Weekly", weekly: true }, + { id: "p2", fraction: 0.3, label: "Premium" }, + ] + const tooltip = formatTrayTooltip(bars, mockMeta, false) + expect(tooltip).toBe("OpenUsage\nPlugin 1: 42%\nPlugin 2: 30%") + }) }) }) diff --git a/src/lib/tray-tooltip.ts b/src/lib/tray-tooltip.ts index 78fef9ac..807fa1ce 100644 --- a/src/lib/tray-tooltip.ts +++ b/src/lib/tray-tooltip.ts @@ -13,16 +13,31 @@ export function formatTrayPercentText(fraction: number | undefined): string { /** * Creates a multi-line tooltip string for the tray icon. * Lists the app name followed by enabled plugins and their usage percentages. + * + * In weekly mode, lines are tagged with their metric label only when the list + * is mixed (at least one provider fell back from weekly). When every provider + * is showing weekly, the tags are redundant and omitted. */ -export function formatTrayTooltip(bars: TrayPrimaryBar[], pluginsMeta: PluginMeta[]): string { +export function formatTrayTooltip( + bars: TrayPrimaryBar[], + pluginsMeta: PluginMeta[], + weeklyMode = false +): string { const lines = ["OpenUsage"] if (bars.length === 0) return lines[0]! - + + const resolved = bars.filter((bar) => bar.label !== undefined) + const hasFallback = resolved.some((bar) => !bar.weekly) + const showTags = weeklyMode && resolved.length > 0 && hasFallback + const metaById = new Map(pluginsMeta.map((p) => [p.id, p])) for (const bar of bars) { const meta = metaById.get(bar.id) - if (meta) { - const percent = formatTrayPercentText(bar.fraction) + if (!meta) continue + const percent = formatTrayPercentText(bar.fraction) + if (showTags && bar.label) { + lines.push(`${meta.name}: ${percent} · ${bar.label}`) + } else { lines.push(`${meta.name}: ${percent}`) } } diff --git a/src/pages/settings.test.tsx b/src/pages/settings.test.tsx index 4ac7452c..e0aa4b04 100644 --- a/src/pages/settings.test.tsx +++ b/src/pages/settings.test.tsx @@ -59,6 +59,8 @@ const defaultProps = { onTimeFormatModeChange: vi.fn(), menubarIconStyle: "provider" as const, onMenubarIconStyleChange: vi.fn(), + menubarMetric: "default" as const, + onMenubarMetricChange: vi.fn(), traySettingsPreview: { bars: [{ id: "a", fraction: 0.7 }], providerBars: [{ id: "a", fraction: 0.7 }], @@ -253,6 +255,25 @@ describe("SettingsPage", () => { expect(onMenubarIconStyleChange).toHaveBeenCalledWith("donut") }) + it("renders the menubar metric control", () => { + render() + expect(screen.getByText("Metric")).toBeInTheDocument() + expect(screen.getByRole("radio", { name: "Default" })).toBeInTheDocument() + expect(screen.getByRole("radio", { name: "Weekly" })).toBeInTheDocument() + }) + + it("clicking Weekly triggers onMenubarMetricChange(\"weekly\")", async () => { + const onMenubarMetricChange = vi.fn() + render( + + ) + await userEvent.click(screen.getByRole("radio", { name: "Weekly" })) + expect(onMenubarMetricChange).toHaveBeenCalledWith("weekly") + }) + it("does not render removed bar icon controls", () => { render() expect(screen.queryByText("Bar Icon")).not.toBeInTheDocument() diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index a013fed4..98106f65 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -24,6 +24,7 @@ import { AUTO_UPDATE_OPTIONS, DISPLAY_MODE_OPTIONS, MENUBAR_ICON_STYLE_OPTIONS, + MENUBAR_METRIC_OPTIONS, RESET_TIMER_DISPLAY_OPTIONS, THEME_OPTIONS, TIME_FORMAT_OPTIONS, @@ -31,6 +32,7 @@ import { type DisplayMode, type GlobalShortcut, type MenubarIconStyle, + type MenubarMetric, type ResetTimerDisplayMode, type ThemeMode, type TimeFormatMode, @@ -275,6 +277,8 @@ interface SettingsPageProps { onTimeFormatModeChange: (value: TimeFormatMode) => void; menubarIconStyle: MenubarIconStyle; onMenubarIconStyleChange: (value: MenubarIconStyle) => void; + menubarMetric: MenubarMetric; + onMenubarMetricChange: (value: MenubarMetric) => void; traySettingsPreview: TraySettingsPreview; globalShortcut: GlobalShortcut; onGlobalShortcutChange: (value: GlobalShortcut) => void; @@ -298,6 +302,8 @@ export function SettingsPage({ onTimeFormatModeChange, menubarIconStyle, onMenubarIconStyleChange, + menubarMetric, + onMenubarMetricChange, traySettingsPreview, globalShortcut, onGlobalShortcutChange, @@ -484,6 +490,29 @@ export function SettingsPage({ })} +

Metric

+
+
+ {MENUBAR_METRIC_OPTIONS.map((option) => { + const isActive = option.value === menubarMetric; + return ( + + ); + })} +
+

App Theme

diff --git a/src/stores/app-preferences-store.ts b/src/stores/app-preferences-store.ts index 07a7f4be..76719544 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_MENUBAR_METRIC, DEFAULT_RESET_TIMER_DISPLAY_MODE, DEFAULT_START_ON_LOGIN, DEFAULT_THEME_MODE, @@ -12,6 +13,7 @@ import { type DisplayMode, type GlobalShortcut, type MenubarIconStyle, + type MenubarMetric, type ResetTimerDisplayMode, type ThemeMode, type TimeFormatMode, @@ -26,6 +28,7 @@ type AppPreferencesStore = { globalShortcut: GlobalShortcut startOnLogin: boolean menubarIconStyle: MenubarIconStyle + menubarMetric: MenubarMetric setAutoUpdateInterval: (value: AutoUpdateIntervalMinutes) => void setThemeMode: (value: ThemeMode) => void setDisplayMode: (value: DisplayMode) => void @@ -34,6 +37,7 @@ type AppPreferencesStore = { setGlobalShortcut: (value: GlobalShortcut) => void setStartOnLogin: (value: boolean) => void setMenubarIconStyle: (value: MenubarIconStyle) => void + setMenubarMetric: (value: MenubarMetric) => void resetState: () => void } @@ -46,6 +50,7 @@ const initialState = { globalShortcut: DEFAULT_GLOBAL_SHORTCUT, startOnLogin: DEFAULT_START_ON_LOGIN, menubarIconStyle: DEFAULT_MENUBAR_ICON_STYLE, + menubarMetric: DEFAULT_MENUBAR_METRIC, } export const useAppPreferencesStore = create((set) => ({ @@ -58,5 +63,6 @@ export const useAppPreferencesStore = create((set) => ({ setGlobalShortcut: (value) => set({ globalShortcut: value }), setStartOnLogin: (value) => set({ startOnLogin: value }), setMenubarIconStyle: (value) => set({ menubarIconStyle: value }), + setMenubarMetric: (value) => set({ menubarMetric: value }), resetState: () => set(initialState), }))