diff --git a/src/widgets/BlockResetTimer.ts b/src/widgets/BlockResetTimer.ts index f505f87f..692b2694 100644 --- a/src/widgets/BlockResetTimer.ts +++ b/src/widgets/BlockResetTimer.ts @@ -38,6 +38,8 @@ import { isUsageDateMode, isUsageInverted, isUsageProgressMode, + isUsageSliderMode, + makeSliderBar, toggleUsageCompact, toggleUsageDateMode, toggleUsageHourFormat, @@ -68,7 +70,7 @@ export class BlockResetTimerWidget implements Widget { handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { if (action === 'toggle-progress') { - return cycleUsageDisplayMode(item, ['compact', 'absolute']); + return cycleUsageDisplayMode(item, ['compact', 'absolute'], true); } if (action === 'toggle-invert') { @@ -105,6 +107,14 @@ export class BlockResetTimerWidget implements Widget { return formatRawOrLabeledValue(item, 'Reset ', `[${progressBar}] ${previewPercent.toFixed(1)}%`); } + if (isUsageSliderMode(displayMode)) { + const slider = makeSliderBar(previewPercent); + const sliderDisplay = displayMode === 'slider' + ? `${slider} ${previewPercent.toFixed(1)}%` + : slider; + return formatRawOrLabeledValue(item, 'Reset ', sliderDisplay); + } + if (dateMode) { const resetAt = formatUsageResetAt( BLOCK_RESET_PREVIEW_AT, @@ -138,6 +148,15 @@ export class BlockResetTimerWidget implements Widget { return formatRawOrLabeledValue(item, 'Reset ', `[${progressBar}] ${percentage}%`); } + if (isUsageSliderMode(displayMode)) { + const percent = inverted ? window.remainingPercent : window.elapsedPercent; + const slider = makeSliderBar(percent); + const sliderDisplay = displayMode === 'slider' + ? `${slider} ${percent.toFixed(1)}%` + : slider; + return formatRawOrLabeledValue(item, 'Reset ', sliderDisplay); + } + if (dateMode) { const timezone = getUsageTimezone(item); const locale = getUsageLocale(item); diff --git a/src/widgets/BlockTimer.ts b/src/widgets/BlockTimer.ts index 86f29484..41a0ef45 100644 --- a/src/widgets/BlockTimer.ts +++ b/src/widgets/BlockTimer.ts @@ -21,6 +21,8 @@ import { isUsageCompact, isUsageInverted, isUsageProgressMode, + isUsageSliderMode, + makeSliderBar, toggleUsageCompact, toggleUsageInverted } from './shared/usage-display'; @@ -47,7 +49,7 @@ export class BlockTimerWidget implements Widget { handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { if (action === 'toggle-progress') { - return cycleUsageDisplayMode(item, ['compact']); + return cycleUsageDisplayMode(item, ['compact'], true); } if (action === 'toggle-invert') { @@ -75,6 +77,14 @@ export class BlockTimerWidget implements Widget { return formatRawOrLabeledValue(item, 'Block ', `[${progressBar}] ${previewPercent.toFixed(1)}%`); } + if (isUsageSliderMode(displayMode)) { + const slider = makeSliderBar(previewPercent); + const sliderDisplay = displayMode === 'slider' + ? `${slider} ${previewPercent.toFixed(1)}%` + : slider; + return formatRawOrLabeledValue(item, 'Block ', sliderDisplay); + } + return formatRawOrLabeledValue(item, 'Block: ', compact ? '3h45m' : '3hr 45m'); } @@ -88,6 +98,14 @@ export class BlockTimerWidget implements Widget { return formatRawOrLabeledValue(item, 'Block ', `[${emptyBar}] 0.0%`); } + if (isUsageSliderMode(displayMode)) { + const emptySlider = makeSliderBar(0); + const sliderDisplay = displayMode === 'slider' + ? `${emptySlider} 0.0%` + : emptySlider; + return formatRawOrLabeledValue(item, 'Block ', sliderDisplay); + } + return formatRawOrLabeledValue(item, 'Block: ', compact ? '0h' : '0hr 0m'); } @@ -99,6 +117,15 @@ export class BlockTimerWidget implements Widget { return formatRawOrLabeledValue(item, 'Block ', `[${progressBar}] ${percentage}%`); } + if (isUsageSliderMode(displayMode)) { + const percent = inverted ? window.remainingPercent : window.elapsedPercent; + const slider = makeSliderBar(percent); + const sliderDisplay = displayMode === 'slider' + ? `${slider} ${percent.toFixed(1)}%` + : slider; + return formatRawOrLabeledValue(item, 'Block ', sliderDisplay); + } + const elapsedTime = formatUsageDuration(window.elapsedMs, compact); return formatRawOrLabeledValue(item, 'Block: ', elapsedTime); } diff --git a/src/widgets/WeeklyResetTimer.ts b/src/widgets/WeeklyResetTimer.ts index 6e259775..51fcd098 100644 --- a/src/widgets/WeeklyResetTimer.ts +++ b/src/widgets/WeeklyResetTimer.ts @@ -44,6 +44,8 @@ import { isUsageDateMode, isUsageInverted, isUsageProgressMode, + isUsageSliderMode, + makeSliderBar, toggleUsageCompact, toggleUsageDateMode, toggleUsageHourFormat, @@ -71,19 +73,24 @@ function toggleWeeklyResetHoursOnly(item: WidgetItem): WidgetItem { function getWeeklyResetModifierText(item: WidgetItem): string | undefined { const displayMode = getUsageDisplayMode(item); const dateMode = isUsageDateMode(item); + const isBarMode = isUsageProgressMode(displayMode) || isUsageSliderMode(displayMode); const modifiers: string[] = []; if (displayMode === 'progress') { modifiers.push('long bar'); } else if (displayMode === 'progress-short') { modifiers.push('medium bar'); + } else if (displayMode === 'slider') { + modifiers.push('short bar'); + } else if (displayMode === 'slider-only') { + modifiers.push('short bar only'); } if (isUsageInverted(item)) { modifiers.push('inverted'); } - if (!isUsageProgressMode(displayMode)) { + if (!isBarMode) { if (isUsageCompact(item)) { modifiers.push('compact'); } @@ -100,12 +107,12 @@ function getWeeklyResetModifierText(item: WidgetItem): string | undefined { } const timezoneModifier = getUsageTimezoneModifier(item); - if (!isUsageProgressMode(displayMode) && dateMode && timezoneModifier) { + if (!isBarMode && dateMode && timezoneModifier) { modifiers.push(timezoneModifier); } const localeModifier = getUsageLocaleModifier(item); - if (!isUsageProgressMode(displayMode) && dateMode && localeModifier) { + if (!isBarMode && dateMode && localeModifier) { modifiers.push(localeModifier); } @@ -127,7 +134,7 @@ export class WeeklyResetTimerWidget implements Widget { handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { if (action === 'toggle-progress') { - return cycleUsageDisplayMode(item, ['compact', 'hours', 'absolute']); + return cycleUsageDisplayMode(item, ['compact', 'hours', 'absolute'], true); } if (action === 'toggle-invert') { @@ -169,6 +176,14 @@ export class WeeklyResetTimerWidget implements Widget { return formatRawOrLabeledValue(item, 'Weekly Reset ', `[${progressBar}] ${previewPercent.toFixed(1)}%`); } + if (isUsageSliderMode(displayMode)) { + const slider = makeSliderBar(previewPercent); + const sliderDisplay = displayMode === 'slider' + ? `${slider} ${previewPercent.toFixed(1)}%` + : slider; + return formatRawOrLabeledValue(item, 'Weekly Reset ', sliderDisplay); + } + if (dateMode) { const resetAt = formatUsageResetAt( WEEKLY_RESET_PREVIEW_AT, @@ -202,6 +217,15 @@ export class WeeklyResetTimerWidget implements Widget { return formatRawOrLabeledValue(item, 'Weekly Reset ', `[${progressBar}] ${percentage}%`); } + if (isUsageSliderMode(displayMode)) { + const percent = inverted ? window.remainingPercent : window.elapsedPercent; + const slider = makeSliderBar(percent); + const sliderDisplay = displayMode === 'slider' + ? `${slider} ${percent.toFixed(1)}%` + : slider; + return formatRawOrLabeledValue(item, 'Weekly Reset ', sliderDisplay); + } + if (dateMode) { const timezone = getUsageTimezone(item); const locale = getUsageLocale(item); @@ -223,7 +247,9 @@ export class WeeklyResetTimerWidget implements Widget { includeTimezone: true }); - if (!item || (!isUsageProgressMode(getUsageDisplayMode(item)) && !isUsageDateMode(item))) { + const mode = item ? getUsageDisplayMode(item) : 'time'; + const isBarMode = isUsageProgressMode(mode) || isUsageSliderMode(mode); + if (!item || (!isBarMode && !isUsageDateMode(item))) { keybinds.push({ key: 'h', label: '(h)ours only', action: 'toggle-hours' }); } diff --git a/src/widgets/__tests__/BlockResetTimer.test.ts b/src/widgets/__tests__/BlockResetTimer.test.ts index ac918ae5..41084b22 100644 --- a/src/widgets/__tests__/BlockResetTimer.test.ts +++ b/src/widgets/__tests__/BlockResetTimer.test.ts @@ -179,6 +179,91 @@ describe('BlockResetTimerWidget', () => { expect(cleared?.metadata?.hour12).toBe('false'); }); + it('renders slider bar with elapsed percentage', () => { + const widget = new BlockResetTimerWidget(); + const item: WidgetItem = { + id: 'reset', + type: 'reset-timer', + metadata: { display: 'slider' } + }; + + mockResolveUsageWindowWithFallback.mockReturnValue({ + sessionDurationMs: 18000000, + elapsedMs: 9000000, + remainingMs: 9000000, + elapsedPercent: 50, + remainingPercent: 50 + }); + + expect(render(widget, item, { usageData: {} })).toBe('Reset ▓▓▓▓▓░░░░░ 50.0%'); + }); + + it('renders slider-only bar without percentage', () => { + const widget = new BlockResetTimerWidget(); + const item: WidgetItem = { + id: 'reset', + type: 'reset-timer', + metadata: { display: 'slider-only' } + }; + + mockResolveUsageWindowWithFallback.mockReturnValue({ + sessionDurationMs: 18000000, + elapsedMs: 9000000, + remainingMs: 9000000, + elapsedPercent: 50, + remainingPercent: 50 + }); + + expect(render(widget, item, { usageData: {} })).toBe('Reset ▓▓▓▓▓░░░░░'); + }); + + it('renders inverted slider using remaining percent', () => { + const widget = new BlockResetTimerWidget(); + const item: WidgetItem = { + id: 'reset', + type: 'reset-timer', + metadata: { display: 'slider', invert: 'true' } + }; + + mockResolveUsageWindowWithFallback.mockReturnValue({ + sessionDurationMs: 18000000, + elapsedMs: 14400000, + remainingMs: 3600000, + elapsedPercent: 80, + remainingPercent: 20 + }); + + expect(render(widget, item, { usageData: {} })).toBe('Reset ▓▓░░░░░░░░ 20.0%'); + }); + + it('exposes invert keybind in slider mode', () => { + const widget = new BlockResetTimerWidget(); + + expect(widget.getCustomKeybinds({ + id: 'reset', + type: 'reset-timer', + metadata: { display: 'slider' } + })).toEqual([ + { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, + { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' } + ]); + }); + + it('shows short bar modifier text in slider modes', () => { + const widget = new BlockResetTimerWidget(); + + expect(widget.getEditorDisplay({ + id: 'reset', + type: 'reset-timer', + metadata: { display: 'slider' } + }).modifierText).toBe('(short bar)'); + expect(widget.getEditorDisplay({ + id: 'reset', + type: 'reset-timer', + metadata: { display: 'slider-only' } + }).modifierText).toBe('(short bar only)'); + }); + runUsageTimerEditorSuite({ baseItem: { id: 'reset', type: 'reset-timer' }, createWidget: () => new BlockResetTimerWidget(), @@ -189,6 +274,7 @@ describe('BlockResetTimerWidget', () => { { key: 't', label: '(t)imestamp', action: 'toggle-date' } ], supportsDateMode: true, + supportsSliderMode: true, expectedModifierText: '(medium bar, inverted)', expectedProgressKeybinds: [ { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, diff --git a/src/widgets/__tests__/BlockTimer.test.ts b/src/widgets/__tests__/BlockTimer.test.ts index c449972b..02ab1a42 100644 --- a/src/widgets/__tests__/BlockTimer.test.ts +++ b/src/widgets/__tests__/BlockTimer.test.ts @@ -100,10 +100,113 @@ describe('BlockTimerWidget', () => { expect(render(widget, { id: 'block', type: 'block-timer', rawValue: true }, { usageData: {} })).toBe('2hr'); }); + it('renders slider bar with elapsed percentage', () => { + const widget = new BlockTimerWidget(); + const item: WidgetItem = { + id: 'block', + type: 'block-timer', + metadata: { display: 'slider' } + }; + + mockResolveUsageWindowWithFallback.mockReturnValue({ + sessionDurationMs: 18000000, + elapsedMs: 9000000, + remainingMs: 9000000, + elapsedPercent: 50, + remainingPercent: 50 + }); + + expect(render(widget, item, { usageData: {} })).toBe('Block ▓▓▓▓▓░░░░░ 50.0%'); + }); + + it('renders slider-only bar without percentage', () => { + const widget = new BlockTimerWidget(); + const item: WidgetItem = { + id: 'block', + type: 'block-timer', + metadata: { display: 'slider-only' } + }; + + mockResolveUsageWindowWithFallback.mockReturnValue({ + sessionDurationMs: 18000000, + elapsedMs: 9000000, + remainingMs: 9000000, + elapsedPercent: 50, + remainingPercent: 50 + }); + + expect(render(widget, item, { usageData: {} })).toBe('Block ▓▓▓▓▓░░░░░'); + }); + + it('renders inverted slider using remaining percent', () => { + const widget = new BlockTimerWidget(); + const item: WidgetItem = { + id: 'block', + type: 'block-timer', + metadata: { display: 'slider', invert: 'true' } + }; + + mockResolveUsageWindowWithFallback.mockReturnValue({ + sessionDurationMs: 18000000, + elapsedMs: 14400000, + remainingMs: 3600000, + elapsedPercent: 80, + remainingPercent: 20 + }); + + expect(render(widget, item, { usageData: {} })).toBe('Block ▓▓░░░░░░░░ 20.0%'); + }); + + it('renders empty slider when no usage or fallback data exists', () => { + const widget = new BlockTimerWidget(); + + mockResolveUsageWindowWithFallback.mockReturnValue(null); + + expect(render(widget, { + id: 'block', + type: 'block-timer', + metadata: { display: 'slider' } + }, { usageData: { error: 'timeout' } })).toBe('Block ░░░░░░░░░░ 0.0%'); + expect(render(widget, { + id: 'block', + type: 'block-timer', + metadata: { display: 'slider-only' } + }, { usageData: { error: 'timeout' } })).toBe('Block ░░░░░░░░░░'); + }); + + it('exposes invert keybind in slider mode', () => { + const widget = new BlockTimerWidget(); + + expect(widget.getCustomKeybinds({ + id: 'block', + type: 'block-timer', + metadata: { display: 'slider' } + })).toEqual([ + { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, + { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' } + ]); + }); + + it('shows short bar modifier text in slider modes', () => { + const widget = new BlockTimerWidget(); + + expect(widget.getEditorDisplay({ + id: 'block', + type: 'block-timer', + metadata: { display: 'slider' } + }).modifierText).toBe('(short bar)'); + expect(widget.getEditorDisplay({ + id: 'block', + type: 'block-timer', + metadata: { display: 'slider-only' } + }).modifierText).toBe('(short bar only)'); + }); + runUsageTimerEditorSuite({ baseItem: { id: 'block', type: 'block-timer' }, createWidget: () => new BlockTimerWidget(), expectedDisplayName: 'Block Timer', + supportsSliderMode: true, expectedModifierText: '(long bar, inverted)', modifierItem: { id: 'block', diff --git a/src/widgets/__tests__/WeeklyResetTimer.test.ts b/src/widgets/__tests__/WeeklyResetTimer.test.ts index de039ba0..728c049d 100644 --- a/src/widgets/__tests__/WeeklyResetTimer.test.ts +++ b/src/widgets/__tests__/WeeklyResetTimer.test.ts @@ -258,6 +258,101 @@ describe('WeeklyResetTimerWidget', () => { ]); }); + it('renders slider bar with elapsed percentage', () => { + const widget = new WeeklyResetTimerWidget(); + const item: WidgetItem = { + id: 'weekly-reset', + type: 'weekly-reset-timer', + metadata: { display: 'slider' } + }; + + mockResolveWeeklyUsageWindow.mockReturnValue({ + sessionDurationMs: 604800000, + elapsedMs: 302400000, + remainingMs: 302400000, + elapsedPercent: 50, + remainingPercent: 50 + }); + + expect(render(widget, item, { usageData: {} })).toBe('Weekly Reset ▓▓▓▓▓░░░░░ 50.0%'); + }); + + it('renders slider-only bar without percentage', () => { + const widget = new WeeklyResetTimerWidget(); + const item: WidgetItem = { + id: 'weekly-reset', + type: 'weekly-reset-timer', + metadata: { display: 'slider-only' } + }; + + mockResolveWeeklyUsageWindow.mockReturnValue({ + sessionDurationMs: 604800000, + elapsedMs: 302400000, + remainingMs: 302400000, + elapsedPercent: 50, + remainingPercent: 50 + }); + + expect(render(widget, item, { usageData: {} })).toBe('Weekly Reset ▓▓▓▓▓░░░░░'); + }); + + it('renders inverted slider using remaining percent', () => { + const widget = new WeeklyResetTimerWidget(); + const item: WidgetItem = { + id: 'weekly-reset', + type: 'weekly-reset-timer', + metadata: { display: 'slider', invert: 'true' } + }; + + mockResolveWeeklyUsageWindow.mockReturnValue({ + sessionDurationMs: 604800000, + elapsedMs: 483840000, + remainingMs: 120960000, + elapsedPercent: 80, + remainingPercent: 20 + }); + + expect(render(widget, item, { usageData: {} })).toBe('Weekly Reset ▓▓░░░░░░░░ 20.0%'); + }); + + it('exposes invert keybind in slider mode and hides hours-only', () => { + const widget = new WeeklyResetTimerWidget(); + + expect(widget.getCustomKeybinds({ + id: 'weekly-reset', + type: 'weekly-reset-timer', + metadata: { display: 'slider' } + })).toEqual([ + { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, + { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' } + ]); + }); + + it('shows short bar modifier text in slider modes', () => { + const widget = new WeeklyResetTimerWidget(); + + expect(widget.getEditorDisplay({ + id: 'weekly-reset', + type: 'weekly-reset-timer', + metadata: { display: 'slider' } + }).modifierText).toBe('(short bar)'); + expect(widget.getEditorDisplay({ + id: 'weekly-reset', + type: 'weekly-reset-timer', + metadata: { display: 'slider-only' } + }).modifierText).toBe('(short bar only)'); + }); + + it('ignores stale hours-only metadata in slider modes', () => { + const widget = new WeeklyResetTimerWidget(); + + expect(widget.getEditorDisplay({ + id: 'weekly-reset', + type: 'weekly-reset-timer', + metadata: { display: 'slider', hours: 'true' } + }).modifierText).toBe('(short bar)'); + }); + runUsageTimerEditorSuite({ baseItem: { id: 'weekly-reset', type: 'weekly-reset-timer' }, createWidget: () => new WeeklyResetTimerWidget(), @@ -269,6 +364,7 @@ describe('WeeklyResetTimerWidget', () => { { key: 'h', label: '(h)ours only', action: 'toggle-hours' } ], supportsDateMode: true, + supportsSliderMode: true, expectedModifierText: '(medium bar, inverted)', expectedProgressKeybinds: [ { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, diff --git a/src/widgets/__tests__/helpers/usage-widget-suites.ts b/src/widgets/__tests__/helpers/usage-widget-suites.ts index f5f3ed1e..37ddce1e 100644 --- a/src/widgets/__tests__/helpers/usage-widget-suites.ts +++ b/src/widgets/__tests__/helpers/usage-widget-suites.ts @@ -43,6 +43,7 @@ interface UsageTimerEditorSuiteConfig { const widget = config.createWidget(); + const lastBarMode = config.supportsSliderMode ? 'slider-only' : 'progress-short'; const updated = widget.handleEditorAction('toggle-progress', { ...config.baseItem, metadata: { - display: 'progress-short', + display: lastBarMode, invert: 'true' } }); @@ -248,7 +250,17 @@ export function runUsageTimerEditorSuite { diff --git a/src/widgets/shared/usage-display.ts b/src/widgets/shared/usage-display.ts index c1faaa5b..66a87643 100644 --- a/src/widgets/shared/usage-display.ts +++ b/src/widgets/shared/usage-display.ts @@ -277,7 +277,10 @@ export function getUsageTimerCustomKeybinds( ): CustomKeybind[] { const keybinds = [PROGRESS_TOGGLE_KEYBIND]; - if (item && isUsageProgressMode(getUsageDisplayMode(item))) { + const mode = item ? getUsageDisplayMode(item) : 'time'; + const isBarMode = isUsageProgressMode(mode) || isUsageSliderMode(mode); + + if (item && isBarMode) { keybinds.push(INVERT_TOGGLE_KEYBIND); } else { keybinds.push(COMPACT_TOGGLE_KEYBIND); @@ -287,7 +290,7 @@ export function getUsageTimerCustomKeybinds( } } - if (item && isUsageDateMode(item) && !isUsageProgressMode(getUsageDisplayMode(item))) { + if (item && isUsageDateMode(item) && !isBarMode) { if (options.includeHourFormat) { keybinds.push(HOUR_FORMAT_TOGGLE_KEYBIND); }