diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index d68fbdc..5a564b1 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -32,7 +32,6 @@ import { type WidgetPickerAction, type WidgetPickerState } from './items-editor/input-handlers'; -import { shouldShowCustomKeybind } from './items-editor/keybind-visibility'; export interface ItemsEditorProps { widgets: WidgetItem[]; @@ -95,12 +94,12 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB setCustomEditorWidget(null); }; - const getVisibleCustomKeybinds = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => { + const getCustomKeybindsForWidget = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => { if (!widgetImpl.getCustomKeybinds) { return []; } - return widgetImpl.getCustomKeybinds().filter(keybind => shouldShowCustomKeybind(widget, keybind)); + return widgetImpl.getCustomKeybinds(widget); }; const openWidgetPicker = (action: WidgetPickerAction) => { @@ -200,7 +199,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB setMoveMode, setShowClearConfirm, openWidgetPicker, - getVisibleCustomKeybinds, + getCustomKeybindsForWidget, setCustomEditorWidget }); }); @@ -263,7 +262,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB if (widgetImpl) { canToggleRaw = widgetImpl.supportsRawValue(); // Get custom keybinds from the widget - customKeybinds = getVisibleCustomKeybinds(widgetImpl, currentWidget); + customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget); } else { canToggleRaw = false; } diff --git a/src/tui/components/items-editor/__tests__/input-handlers.test.ts b/src/tui/components/items-editor/__tests__/input-handlers.test.ts index 8064d3a..160d84c 100644 --- a/src/tui/components/items-editor/__tests__/input-handlers.test.ts +++ b/src/tui/components/items-editor/__tests__/input-handlers.test.ts @@ -166,7 +166,7 @@ describe('items-editor input handlers', () => { setMoveMode: vi.fn(), setShowClearConfirm: vi.fn(), openWidgetPicker: vi.fn(), - getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [], + getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], setCustomEditorWidget: vi.fn() }); @@ -192,7 +192,7 @@ describe('items-editor input handlers', () => { setMoveMode: vi.fn(), setShowClearConfirm: vi.fn(), openWidgetPicker: vi.fn(), - getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [], + getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], setCustomEditorWidget: vi.fn() }); @@ -218,7 +218,7 @@ describe('items-editor input handlers', () => { setMoveMode: vi.fn(), setShowClearConfirm: vi.fn(), openWidgetPicker: vi.fn(), - getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [], + getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], setCustomEditorWidget: vi.fn() }); @@ -244,7 +244,7 @@ describe('items-editor input handlers', () => { setMoveMode: vi.fn(), setShowClearConfirm: vi.fn(), openWidgetPicker: vi.fn(), - getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [], + getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], setCustomEditorWidget: vi.fn() }); @@ -271,7 +271,7 @@ describe('items-editor input handlers', () => { setMoveMode: vi.fn(), setShowClearConfirm: vi.fn(), openWidgetPicker: vi.fn(), - getVisibleCustomKeybinds: widgetImpl => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds() : [], + getCustomKeybindsForWidget: (widgetImpl, widget) => widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : [], setCustomEditorWidget }); diff --git a/src/tui/components/items-editor/__tests__/keybind-visibility.test.ts b/src/tui/components/items-editor/__tests__/keybind-visibility.test.ts deleted file mode 100644 index 98919f1..0000000 --- a/src/tui/components/items-editor/__tests__/keybind-visibility.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - describe, - expect, - it -} from 'vitest'; - -import type { - CustomKeybind, - WidgetItem -} from '../../../../types/Widget'; -import { shouldShowCustomKeybind } from '../keybind-visibility'; - -const TOGGLE_COMPACT_KEYBIND: CustomKeybind = { key: 's', label: '(s)hort time', action: 'toggle-compact' }; -const TOGGLE_INVERT_KEYBIND: CustomKeybind = { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' }; -const EDIT_LIST_LIMIT_KEYBIND: CustomKeybind = { key: 'l', label: '(l)imit list', action: 'edit-list-limit' }; - -function createWidget(type: string, metadata?: Record): WidgetItem { - return { - id: 'widget', - type, - metadata - }; -} - -describe('shouldShowCustomKeybind', () => { - it('shows invert only in progress modes', () => { - expect(shouldShowCustomKeybind(createWidget('block-timer'), TOGGLE_INVERT_KEYBIND)).toBe(false); - expect(shouldShowCustomKeybind(createWidget('block-timer', { display: 'progress' }), TOGGLE_INVERT_KEYBIND)).toBe(true); - expect(shouldShowCustomKeybind(createWidget('block-timer', { display: 'progress-short' }), TOGGLE_INVERT_KEYBIND)).toBe(true); - }); - - it('hides short time in progress modes', () => { - expect(shouldShowCustomKeybind(createWidget('block-timer'), TOGGLE_COMPACT_KEYBIND)).toBe(true); - expect(shouldShowCustomKeybind(createWidget('block-timer', { display: 'time' }), TOGGLE_COMPACT_KEYBIND)).toBe(true); - expect(shouldShowCustomKeybind(createWidget('block-timer', { display: 'progress' }), TOGGLE_COMPACT_KEYBIND)).toBe(false); - expect(shouldShowCustomKeybind(createWidget('block-timer', { display: 'progress-short' }), TOGGLE_COMPACT_KEYBIND)).toBe(false); - }); - - it('shows list limit only for skills list mode', () => { - expect(shouldShowCustomKeybind(createWidget('skills'), EDIT_LIST_LIMIT_KEYBIND)).toBe(false); - expect(shouldShowCustomKeybind(createWidget('skills', { mode: 'count' }), EDIT_LIST_LIMIT_KEYBIND)).toBe(false); - expect(shouldShowCustomKeybind(createWidget('skills', { mode: 'list' }), EDIT_LIST_LIMIT_KEYBIND)).toBe(true); - }); -}); \ No newline at end of file diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index 0d3cab0..7dafe49 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -338,7 +338,7 @@ export interface HandleNormalInputModeArgs { setMoveMode: (moveMode: boolean) => void; setShowClearConfirm: (show: boolean) => void; openWidgetPicker: (action: WidgetPickerAction) => void; - getVisibleCustomKeybinds: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[]; + getCustomKeybindsForWidget: (widgetImpl: Widget, widget: WidgetItem) => CustomKeybind[]; setCustomEditorWidget: (state: CustomEditorWidgetState | null) => void; } @@ -354,7 +354,7 @@ export function handleNormalInputMode({ setMoveMode, setShowClearConfirm, openWidgetPicker, - getVisibleCustomKeybinds, + getCustomKeybindsForWidget, setCustomEditorWidget }: HandleNormalInputModeArgs): void { if (key.upArrow && widgets.length > 0) { @@ -436,7 +436,7 @@ export function handleNormalInputMode({ return; } - const customKeybinds = getVisibleCustomKeybinds(widgetImpl, currentWidget); + const customKeybinds = getCustomKeybindsForWidget(widgetImpl, currentWidget); const matchedKeybind = customKeybinds.find(kb => kb.key === input); if (matchedKeybind && !key.ctrl) { diff --git a/src/tui/components/items-editor/keybind-visibility.ts b/src/tui/components/items-editor/keybind-visibility.ts deleted file mode 100644 index b130e61..0000000 --- a/src/tui/components/items-editor/keybind-visibility.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { - CustomKeybind, - WidgetItem -} from '../../../types/Widget'; - -function isProgressMode(widget: WidgetItem): boolean { - const mode = widget.metadata?.display; - return mode === 'progress' || mode === 'progress-short'; -} - -export function shouldShowCustomKeybind(widget: WidgetItem, keybind: CustomKeybind): boolean { - if (keybind.action === 'edit-list-limit') { - return widget.type === 'skills' && widget.metadata?.mode === 'list'; - } - - if (keybind.action === 'toggle-invert') { - return isProgressMode(widget); - } - - if (keybind.action === 'toggle-compact') { - return !isProgressMode(widget); - } - - return true; -} \ No newline at end of file diff --git a/src/types/Widget.ts b/src/types/Widget.ts index 463a4ca..fd3e5f8 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -37,7 +37,7 @@ export interface Widget { getCategory(): string; getEditorDisplay(item: WidgetItem): WidgetEditorDisplay; render(item: WidgetItem, context: RenderContext, settings: Settings): string | null; - getCustomKeybinds?(): CustomKeybind[]; + getCustomKeybinds?(item?: WidgetItem): CustomKeybind[]; renderEditor?(props: WidgetEditorProps): React.ReactElement | null; supportsRawValue(): boolean; supportsColors(item: WidgetItem): boolean; diff --git a/src/utils/__tests__/usage.test.ts b/src/utils/__tests__/usage.test.ts index f96f42b..1064d41 100644 --- a/src/utils/__tests__/usage.test.ts +++ b/src/utils/__tests__/usage.test.ts @@ -134,16 +134,40 @@ describe('usage window helpers', () => { }); it('formats duration in block timer style', () => { - expect(formatUsageDuration(0)).toBe('0hr'); + expect(formatUsageDuration(0)).toBe('0m'); expect(formatUsageDuration(3 * 60 * 60 * 1000)).toBe('3hr'); expect(formatUsageDuration(3.5 * 60 * 60 * 1000)).toBe('3hr 30m'); expect(formatUsageDuration(4 * 60 * 60 * 1000 + 5 * 60 * 1000)).toBe('4hr 5m'); }); + it('formats duration with days when >= 24h', () => { + expect(formatUsageDuration(25 * 60 * 60 * 1000)).toBe('1d 1hr'); + expect(formatUsageDuration(36.5 * 60 * 60 * 1000)).toBe('1d 12hr 30m'); + expect(formatUsageDuration(168 * 60 * 60 * 1000)).toBe('7d'); + }); + it('formats duration in compact style', () => { - expect(formatUsageDuration(0, true)).toBe('0h'); + expect(formatUsageDuration(0, true)).toBe('0m'); expect(formatUsageDuration(3 * 60 * 60 * 1000, true)).toBe('3h'); expect(formatUsageDuration(3.5 * 60 * 60 * 1000, true)).toBe('3h30m'); expect(formatUsageDuration(4 * 60 * 60 * 1000 + 5 * 60 * 1000, true)).toBe('4h5m'); }); + + it('formats duration with days in compact style when >= 24h', () => { + expect(formatUsageDuration(25 * 60 * 60 * 1000, true)).toBe('1d1h'); + expect(formatUsageDuration(36.5 * 60 * 60 * 1000, true)).toBe('1d12h30m'); + expect(formatUsageDuration(168 * 60 * 60 * 1000, true)).toBe('7d'); + }); + + it('formats duration without days when requested', () => { + expect(formatUsageDuration(25 * 60 * 60 * 1000, false, false)).toBe('25hr'); + expect(formatUsageDuration(36.5 * 60 * 60 * 1000, false, false)).toBe('36hr 30m'); + expect(formatUsageDuration(168 * 60 * 60 * 1000, false, false)).toBe('168hr'); + }); + + it('formats duration without days in compact style when requested', () => { + expect(formatUsageDuration(25 * 60 * 60 * 1000, true, false)).toBe('25h'); + expect(formatUsageDuration(36.5 * 60 * 60 * 1000, true, false)).toBe('36h30m'); + expect(formatUsageDuration(168 * 60 * 60 * 1000, true, false)).toBe('168h'); + }); }); \ No newline at end of file diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index e3b5a95..94fb7fd 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -352,12 +352,11 @@ function renderPowerlineStatusLine( // - Background: previous widget's background color // Build separator with raw ANSI codes to avoid reset issues + let separatorOutput: string; // Check if adjacent widgets have the same background color const sameBackground = widget.bgColor && nextWidget.bgColor && widget.bgColor === nextWidget.bgColor; - let separatorOutput: string; - if (shouldInvert) { // Inverted: swap fg/bg logic if (widget.bgColor && nextWidget.bgColor) { diff --git a/src/utils/usage-windows.ts b/src/utils/usage-windows.ts index 2d469c0..a1f70c1 100644 --- a/src/utils/usage-windows.ts +++ b/src/utils/usage-windows.ts @@ -89,20 +89,17 @@ export function resolveWeeklyUsageWindow(usageData: UsageData, nowMs = Date.now( return getWeeklyUsageWindowFromResetAt(usageData.weeklyResetAt, nowMs); } -export function formatUsageDuration(durationMs: number, compact = false): string { +export function formatUsageDuration(durationMs: number, compact = false, useDays = true): string { const clampedMs = Math.max(0, durationMs); - const elapsedHours = Math.floor(clampedMs / (1000 * 60 * 60)); - const elapsedMinutes = Math.floor((clampedMs % (1000 * 60 * 60)) / (1000 * 60)); - - if (compact) { - return elapsedMinutes === 0 ? `${elapsedHours}h` : `${elapsedHours}h${elapsedMinutes}m`; - } - - if (elapsedMinutes === 0) { - return `${elapsedHours}hr`; - } - - return `${elapsedHours}hr ${elapsedMinutes}m`; + const totalHours = Math.floor(clampedMs / (1000 * 60 * 60)); + const m = Math.floor((clampedMs % (1000 * 60 * 60)) / (1000 * 60)); + + const hLabel = compact ? 'h' : 'hr'; + const sep = compact ? '' : ' '; + const d = useDays ? Math.floor(totalHours / 24) : 0; + const h = useDays ? totalHours % 24 : totalHours; + const parts = [d > 0 && `${d}d`, h > 0 && `${h}${hLabel}`, m > 0 && `${m}m`].filter(Boolean); + return parts.length > 0 ? parts.join(sep) : '0m'; } export function getUsageErrorMessage(error: UsageError): string { diff --git a/src/widgets/BlockResetTimer.ts b/src/widgets/BlockResetTimer.ts index 4c23b92..45d80ca 100644 --- a/src/widgets/BlockResetTimer.ts +++ b/src/widgets/BlockResetTimer.ts @@ -18,6 +18,7 @@ import { getUsageDisplayMode, getUsageDisplayModifierText, getUsageProgressBarWidth, + getUsageTimerCustomKeybinds, isUsageCompact, isUsageInverted, isUsageProgressMode, @@ -47,7 +48,7 @@ export class BlockResetTimerWidget implements Widget { handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { if (action === 'toggle-progress') { - return cycleUsageDisplayMode(item); + return cycleUsageDisplayMode(item, ['compact']); } if (action === 'toggle-invert') { @@ -101,12 +102,8 @@ export class BlockResetTimerWidget implements Widget { return formatRawOrLabeledValue(item, 'Reset: ', remainingTime); } - getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, - { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' }, - { key: 's', label: '(s)hort time', action: 'toggle-compact' } - ]; + getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { + return getUsageTimerCustomKeybinds(item); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/BlockTimer.ts b/src/widgets/BlockTimer.ts index bfce5d5..bbd0420 100644 --- a/src/widgets/BlockTimer.ts +++ b/src/widgets/BlockTimer.ts @@ -17,6 +17,7 @@ import { getUsageDisplayMode, getUsageDisplayModifierText, getUsageProgressBarWidth, + getUsageTimerCustomKeybinds, isUsageCompact, isUsageInverted, isUsageProgressMode, @@ -46,7 +47,7 @@ export class BlockTimerWidget implements Widget { handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { if (action === 'toggle-progress') { - return cycleUsageDisplayMode(item); + return cycleUsageDisplayMode(item, ['compact']); } if (action === 'toggle-invert') { @@ -102,12 +103,8 @@ export class BlockTimerWidget implements Widget { return formatRawOrLabeledValue(item, 'Block: ', elapsedTime); } - getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, - { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' }, - { key: 's', label: '(s)hort time', action: 'toggle-compact' } - ]; + getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { + return getUsageTimerCustomKeybinds(item); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/SessionUsage.ts b/src/widgets/SessionUsage.ts index 32ed6dd..1805e91 100644 --- a/src/widgets/SessionUsage.ts +++ b/src/widgets/SessionUsage.ts @@ -16,6 +16,7 @@ import { cycleUsageDisplayMode, getUsageDisplayMode, getUsageDisplayModifierText, + getUsagePercentCustomKeybinds, getUsageProgressBarWidth, isUsageInverted, isUsageProgressMode, @@ -81,11 +82,8 @@ export class SessionUsageWidget implements Widget { return formatRawOrLabeledValue(item, 'Session: ', `${percent.toFixed(1)}%`); } - getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, - { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' } - ]; + getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { + return getUsagePercentCustomKeybinds(item); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/Skills.tsx b/src/widgets/Skills.tsx index 1dd7446..cbed430 100644 --- a/src/widgets/Skills.tsx +++ b/src/widgets/Skills.tsx @@ -20,6 +20,7 @@ import { shouldInsertInput } from '../utils/input-guards'; import { makeModifierText } from './shared/editor-display'; import { isMetadataFlagEnabled, + removeMetadataKeys, toggleMetadataFlag } from './shared/metadata'; @@ -73,12 +74,17 @@ export class SkillsWidget implements Widget { ]; } - getCustomKeybinds(): CustomKeybind[] { - return [ + getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { + const keybinds: CustomKeybind[] = [ { key: 'v', label: '(v)iew: last/count/list', action: 'cycle-mode' }, - { key: 'h', label: '(h)ide when empty', action: TOGGLE_HIDE_EMPTY_ACTION }, - { key: 'l', label: '(l)imit', action: EDIT_LIST_LIMIT_ACTION } + { key: 'h', label: '(h)ide when empty', action: TOGGLE_HIDE_EMPTY_ACTION } ]; + + if (item && this.getMode(item) === 'list') { + keybinds.push({ key: 'l', label: '(l)imit', action: EDIT_LIST_LIMIT_ACTION }); + } + + return keybinds; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { @@ -98,7 +104,8 @@ export class SkillsWidget implements Widget { handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { if (action === 'cycle-mode') { const next = MODES[(MODES.indexOf(this.getMode(item)) + 1) % MODES.length] ?? 'current'; - return { ...item, metadata: { ...item.metadata, mode: next } }; + const nextItem = next === 'list' ? item : removeMetadataKeys(item, [LIST_LIMIT_KEY]); + return { ...nextItem, metadata: { ...nextItem.metadata, mode: next } }; } if (action === TOGGLE_HIDE_EMPTY_ACTION) { return toggleMetadataFlag(item, HIDE_WHEN_EMPTY_KEY); diff --git a/src/widgets/WeeklyResetTimer.ts b/src/widgets/WeeklyResetTimer.ts index ead9af7..a293af9 100644 --- a/src/widgets/WeeklyResetTimer.ts +++ b/src/widgets/WeeklyResetTimer.ts @@ -12,12 +12,17 @@ import { resolveWeeklyUsageWindow } from '../utils/usage'; +import { makeModifierText } from './shared/editor-display'; +import { + isMetadataFlagEnabled, + toggleMetadataFlag +} from './shared/metadata'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; import { cycleUsageDisplayMode, getUsageDisplayMode, - getUsageDisplayModifierText, getUsageProgressBarWidth, + getUsageTimerCustomKeybinds, isUsageCompact, isUsageInverted, isUsageProgressMode, @@ -32,6 +37,43 @@ function makeTimerProgressBar(percent: number, width: number): string { return '█'.repeat(filledWidth) + '░'.repeat(emptyWidth); } +const WEEKLY_PREVIEW_DURATION_MS = 36.5 * 60 * 60 * 1000; + +function isWeeklyResetHoursOnly(item: WidgetItem): boolean { + return isMetadataFlagEnabled(item, 'hours'); +} + +function toggleWeeklyResetHoursOnly(item: WidgetItem): WidgetItem { + return toggleMetadataFlag(item, 'hours'); +} + +function getWeeklyResetModifierText(item: WidgetItem): string | undefined { + const displayMode = getUsageDisplayMode(item); + const modifiers: string[] = []; + + if (displayMode === 'progress') { + modifiers.push('progress bar'); + } else if (displayMode === 'progress-short') { + modifiers.push('short bar'); + } + + if (isUsageInverted(item)) { + modifiers.push('inverted'); + } + + if (!isUsageProgressMode(displayMode)) { + if (isUsageCompact(item)) { + modifiers.push('compact'); + } + + if (isWeeklyResetHoursOnly(item)) { + modifiers.push('hours only'); + } + } + + return makeModifierText(modifiers); +} + export class WeeklyResetTimerWidget implements Widget { getDefaultColor(): string { return 'brightBlue'; } getDescription(): string { return 'Shows time remaining until weekly usage reset'; } @@ -41,13 +83,13 @@ export class WeeklyResetTimerWidget implements Widget { getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName(), - modifierText: getUsageDisplayModifierText(item, { includeCompact: true }) + modifierText: getWeeklyResetModifierText(item) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { if (action === 'toggle-progress') { - return cycleUsageDisplayMode(item); + return cycleUsageDisplayMode(item, ['compact', 'hours']); } if (action === 'toggle-invert') { @@ -58,6 +100,10 @@ export class WeeklyResetTimerWidget implements Widget { return toggleUsageCompact(item); } + if (action === 'toggle-hours') { + return toggleWeeklyResetHoursOnly(item); + } + return null; } @@ -65,6 +111,7 @@ export class WeeklyResetTimerWidget implements Widget { const displayMode = getUsageDisplayMode(item); const inverted = isUsageInverted(item); const compact = isUsageCompact(item); + const useDays = !isWeeklyResetHoursOnly(item); if (context.isPreview) { const previewPercent = inverted ? 90.0 : 10.0; @@ -75,7 +122,7 @@ export class WeeklyResetTimerWidget implements Widget { return formatRawOrLabeledValue(item, 'Weekly Reset ', `[${progressBar}] ${previewPercent.toFixed(1)}%`); } - return formatRawOrLabeledValue(item, 'Weekly Reset: ', compact ? '36h30m' : '36hr 30m'); + return formatRawOrLabeledValue(item, 'Weekly Reset: ', formatUsageDuration(WEEKLY_PREVIEW_DURATION_MS, compact, useDays)); } const usageData = context.usageData ?? {}; @@ -97,16 +144,18 @@ export class WeeklyResetTimerWidget implements Widget { return formatRawOrLabeledValue(item, 'Weekly Reset ', `[${progressBar}] ${percentage}%`); } - const remainingTime = formatUsageDuration(window.remainingMs, compact); + const remainingTime = formatUsageDuration(window.remainingMs, compact, useDays); return formatRawOrLabeledValue(item, 'Weekly Reset: ', remainingTime); } - getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, - { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' }, - { key: 's', label: '(s)hort time', action: 'toggle-compact' } - ]; + getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { + const keybinds = getUsageTimerCustomKeybinds(item); + + if (!item || !isUsageProgressMode(getUsageDisplayMode(item))) { + keybinds.push({ key: 'h', label: '(h)ours only', action: 'toggle-hours' }); + } + + return keybinds; } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/WeeklyUsage.ts b/src/widgets/WeeklyUsage.ts index 161f755..c485804 100644 --- a/src/widgets/WeeklyUsage.ts +++ b/src/widgets/WeeklyUsage.ts @@ -16,6 +16,7 @@ import { cycleUsageDisplayMode, getUsageDisplayMode, getUsageDisplayModifierText, + getUsagePercentCustomKeybinds, getUsageProgressBarWidth, isUsageInverted, isUsageProgressMode, @@ -81,11 +82,8 @@ export class WeeklyUsageWidget implements Widget { return formatRawOrLabeledValue(item, 'Weekly: ', `${percent.toFixed(1)}%`); } - getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, - { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' } - ]; + getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { + return getUsagePercentCustomKeybinds(item); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/__tests__/Skills.test.ts b/src/widgets/__tests__/Skills.test.ts index a8eae7c..fc26fc2 100644 --- a/src/widgets/__tests__/Skills.test.ts +++ b/src/widgets/__tests__/Skills.test.ts @@ -18,7 +18,15 @@ function render(item: WidgetItem, context: RenderContext): string | null { describe('SkillsWidget', () => { it('uses v as the mode toggle keybind', () => { const widget = new SkillsWidget(); - expect(widget.getCustomKeybinds()).toEqual([ + expect(widget.getCustomKeybinds({ id: 'skills', type: 'skills' })).toEqual([ + { key: 'v', label: '(v)iew: last/count/list', action: 'cycle-mode' }, + { key: 'h', label: '(h)ide when empty', action: 'toggle-hide-empty' } + ]); + expect(widget.getCustomKeybinds({ + id: 'skills', + type: 'skills', + metadata: { mode: 'list' } + })).toEqual([ { key: 'v', label: '(v)iew: last/count/list', action: 'cycle-mode' }, { key: 'h', label: '(h)ide when empty', action: 'toggle-hide-empty' }, { key: 'l', label: '(l)imit', action: 'edit-list-limit' } @@ -37,6 +45,21 @@ describe('SkillsWidget', () => { expect(current?.metadata?.mode).toBe('current'); }); + it('clears list limit metadata when leaving list mode', () => { + const widget = new SkillsWidget(); + const updated = widget.handleEditorAction('cycle-mode', { + id: 'skills', + type: 'skills', + metadata: { + mode: 'list', + listLimit: '2' + } + }); + + expect(updated?.metadata?.mode).toBe('current'); + expect(updated?.metadata?.listLimit).toBeUndefined(); + }); + it('toggles hide-when-empty metadata', () => { const widget = new SkillsWidget(); const base: WidgetItem = { id: 'skills', type: 'skills' }; diff --git a/src/widgets/__tests__/WeeklyResetTimer.test.ts b/src/widgets/__tests__/WeeklyResetTimer.test.ts index 341095b..6ba218e 100644 --- a/src/widgets/__tests__/WeeklyResetTimer.test.ts +++ b/src/widgets/__tests__/WeeklyResetTimer.test.ts @@ -39,7 +39,17 @@ describe('WeeklyResetTimerWidget', () => { it('renders preview using weekly reset format', () => { const widget = new WeeklyResetTimerWidget(); - expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer' }, { isPreview: true })).toBe('Weekly Reset: 36hr 30m'); + expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer' }, { isPreview: true })).toBe('Weekly Reset: 1d 12hr 30m'); + }); + + it('renders preview in hours-only mode when toggled', () => { + const widget = new WeeklyResetTimerWidget(); + + expect(render(widget, { + id: 'weekly-reset', + type: 'weekly-reset-timer', + metadata: { hours: 'true' } + }, { isPreview: true })).toBe('Weekly Reset: 36hr 30m'); }); it('renders remaining time in time mode', () => { @@ -55,6 +65,27 @@ describe('WeeklyResetTimerWidget', () => { mockFormatUsageDuration.mockReturnValue('134hr 40m'); expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer' }, { usageData: {} })).toBe('Weekly Reset: 134hr 40m'); + expect(mockFormatUsageDuration).toHaveBeenCalledWith(484800000, false, true); + }); + + it('renders remaining time in hours-only mode', () => { + const widget = new WeeklyResetTimerWidget(); + + mockResolveWeeklyUsageWindow.mockReturnValue({ + sessionDurationMs: 604800000, + elapsedMs: 120000000, + remainingMs: 484800000, + elapsedPercent: 19.8412698413, + remainingPercent: 80.1587301587 + }); + mockFormatUsageDuration.mockReturnValue('134hr 40m'); + + expect(render(widget, { + id: 'weekly-reset', + type: 'weekly-reset-timer', + metadata: { hours: 'true' } + }, { usageData: {} })).toBe('Weekly Reset: 134hr 40m'); + expect(mockFormatUsageDuration).toHaveBeenCalledWith(484800000, false, false); }); it('renders short progress bar with inverted fill', () => { @@ -111,10 +142,60 @@ describe('WeeklyResetTimerWidget', () => { expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer', rawValue: true }, { usageData: {} })).toBe('120hr 15m'); }); + it('toggles hours-only metadata and shows hours-only modifier text', () => { + const widget = new WeeklyResetTimerWidget(); + const baseItem: WidgetItem = { id: 'weekly-reset', type: 'weekly-reset-timer' }; + + const hoursOnly = widget.handleEditorAction('toggle-hours', baseItem); + const cleared = widget.handleEditorAction('toggle-hours', hoursOnly ?? baseItem); + + expect(hoursOnly?.metadata?.hours).toBe('true'); + expect(cleared?.metadata?.hours).toBe('false'); + expect(widget.getEditorDisplay(baseItem).modifierText).toBeUndefined(); + expect(widget.getEditorDisplay({ + ...baseItem, + metadata: { hours: 'true' } + }).modifierText).toBe('(hours only)'); + }); + + it('clears compact and hours-only metadata when cycling into progress mode', () => { + const widget = new WeeklyResetTimerWidget(); + const updated = widget.handleEditorAction('toggle-progress', { + id: 'weekly-reset', + type: 'weekly-reset-timer', + metadata: { + compact: 'true', + hours: 'true' + } + }); + + expect(updated?.metadata?.display).toBe('progress'); + expect(updated?.metadata?.compact).toBeUndefined(); + expect(updated?.metadata?.hours).toBeUndefined(); + }); + + it('ignores stale hours-only metadata in progress mode editor modifiers', () => { + const widget = new WeeklyResetTimerWidget(); + + expect(widget.getEditorDisplay({ + id: 'weekly-reset', + type: 'weekly-reset-timer', + metadata: { + display: 'progress', + hours: 'true' + } + }).modifierText).toBe('(progress bar)'); + }); + runUsageTimerEditorSuite({ baseItem: { id: 'weekly-reset', type: 'weekly-reset-timer' }, createWidget: () => new WeeklyResetTimerWidget(), expectedDisplayName: 'Weekly Reset Timer', + expectedTimeKeybinds: [ + { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, + { key: 's', label: '(s)hort time', action: 'toggle-compact' }, + { key: 'h', label: '(h)ours only', action: 'toggle-hours' } + ], expectedModifierText: '(short bar, inverted)', modifierItem: { id: 'weekly-reset', diff --git a/src/widgets/__tests__/helpers/usage-widget-suites.ts b/src/widgets/__tests__/helpers/usage-widget-suites.ts index 0e9c9af..a73cca3 100644 --- a/src/widgets/__tests__/helpers/usage-widget-suites.ts +++ b/src/widgets/__tests__/helpers/usage-widget-suites.ts @@ -13,7 +13,7 @@ import type { } from '../../../types/Widget'; interface UsageWidgetLike { - getCustomKeybinds(): CustomKeybind[]; + getCustomKeybinds(item?: WidgetItem): CustomKeybind[]; getEditorDisplay(item: WidgetItem): WidgetEditorDisplay; handleEditorAction(action: string, item: WidgetItem): WidgetItem | null; supportsRawValue(): boolean; @@ -41,21 +41,31 @@ interface UsageTimerEditorSuiteConfig TWidget; expectedDisplayName: string; + expectedProgressKeybinds?: CustomKeybind[]; expectedModifierText: string; modifierItem: WidgetItem; + expectedTimeKeybinds?: CustomKeybind[]; } const EXPECTED_USAGE_KEYBINDS: CustomKeybind[] = [ + { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' } +]; + +const EXPECTED_USAGE_PROGRESS_KEYBINDS: CustomKeybind[] = [ { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' } ]; -const EXPECTED_TIMER_KEYBINDS: CustomKeybind[] = [ +const EXPECTED_TIMER_TIME_KEYBINDS: CustomKeybind[] = [ { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, - { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' }, { key: 's', label: '(s)hort time', action: 'toggle-compact' } ]; +const EXPECTED_TIMER_PROGRESS_KEYBINDS: CustomKeybind[] = [ + { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, + { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' } +]; + function getUsageContext(field: 'sessionUsage' | 'weeklyUsage', value: number): RenderContext { return field === 'sessionUsage' ? { usageData: { sessionUsage: value } } @@ -67,11 +77,12 @@ export function runUsagePercentWidgetSuite(conf vi.clearAllMocks(); }); - it('exposes progress and invert keybinds', () => { + it('exposes widget-managed keybinds for time and progress modes', () => { const widget = config.createWidget(); expect(widget.supportsRawValue()).toBe(true); - expect(widget.getCustomKeybinds()).toEqual(EXPECTED_USAGE_KEYBINDS); + expect(widget.getCustomKeybinds(config.baseItem)).toEqual(EXPECTED_USAGE_KEYBINDS); + expect(widget.getCustomKeybinds(config.progressItem)).toEqual(EXPECTED_USAGE_PROGRESS_KEYBINDS); }); it.each([ @@ -170,12 +181,13 @@ export function runUsageTimerEditorSuite { + it('supports raw value and exposes widget-managed keybinds for time and progress modes', () => { const widget = config.createWidget(); expect(widget.getDisplayName()).toBe(config.expectedDisplayName); expect(widget.supportsRawValue()).toBe(true); - expect(widget.getCustomKeybinds()).toEqual(EXPECTED_TIMER_KEYBINDS); + expect(widget.getCustomKeybinds(config.baseItem)).toEqual(config.expectedTimeKeybinds ?? EXPECTED_TIMER_TIME_KEYBINDS); + expect(widget.getCustomKeybinds(config.modifierItem)).toEqual(config.expectedProgressKeybinds ?? EXPECTED_TIMER_PROGRESS_KEYBINDS); }); it('clears invert metadata when cycling back to time mode', () => { @@ -204,6 +216,17 @@ export function runUsageTimerEditorSuite { + const widget = config.createWidget(); + const updated = widget.handleEditorAction('toggle-progress', { + ...config.baseItem, + metadata: { compact: 'true' } + }); + + expect(updated?.metadata?.display).toBe('progress'); + expect(updated?.metadata?.compact).toBeUndefined(); + }); + it('toggles invert metadata and shows editor modifiers', () => { const widget = config.createWidget(); diff --git a/src/widgets/shared/metadata.ts b/src/widgets/shared/metadata.ts index 4a469ca..5256287 100644 --- a/src/widgets/shared/metadata.ts +++ b/src/widgets/shared/metadata.ts @@ -12,4 +12,15 @@ export function toggleMetadataFlag(item: WidgetItem, key: string): WidgetItem { [key]: (!isMetadataFlagEnabled(item, key)).toString() } }; +} + +export function removeMetadataKeys(item: WidgetItem, keys: string[]): WidgetItem { + const nextMetadata = Object.fromEntries( + Object.entries(item.metadata ?? {}).filter(([key]) => !keys.includes(key)) + ); + + return { + ...item, + metadata: Object.keys(nextMetadata).length > 0 ? nextMetadata : undefined + }; } \ No newline at end of file diff --git a/src/widgets/shared/usage-display.ts b/src/widgets/shared/usage-display.ts index 1c97e06..e42e99e 100644 --- a/src/widgets/shared/usage-display.ts +++ b/src/widgets/shared/usage-display.ts @@ -1,13 +1,21 @@ -import type { WidgetItem } from '../../types/Widget'; +import type { + CustomKeybind, + WidgetItem +} from '../../types/Widget'; import { makeModifierText } from './editor-display'; import { isMetadataFlagEnabled, + removeMetadataKeys, toggleMetadataFlag } from './metadata'; export type UsageDisplayMode = 'time' | 'progress' | 'progress-short'; +const PROGRESS_TOGGLE_KEYBIND: CustomKeybind = { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }; +const INVERT_TOGGLE_KEYBIND: CustomKeybind = { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' }; +const COMPACT_TOGGLE_KEYBIND: CustomKeybind = { key: 's', label: '(s)hort time', action: 'toggle-compact' }; + export function getUsageDisplayMode(item: WidgetItem): UsageDisplayMode { const mode = item.metadata?.display; if (mode === 'progress' || mode === 'progress-short') { @@ -55,14 +63,14 @@ export function getUsageDisplayModifierText( modifiers.push('inverted'); } - if (options.includeCompact && isUsageCompact(item)) { + if (options.includeCompact && !isUsageProgressMode(mode) && isUsageCompact(item)) { modifiers.push('compact'); } return makeModifierText(modifiers); } -export function cycleUsageDisplayMode(item: WidgetItem): WidgetItem { +export function cycleUsageDisplayMode(item: WidgetItem, disabledInProgressKeys: string[] = []): WidgetItem { const currentMode = getUsageDisplayMode(item); const nextMode: UsageDisplayMode = currentMode === 'time' ? 'progress' @@ -70,21 +78,42 @@ export function cycleUsageDisplayMode(item: WidgetItem): WidgetItem { ? 'progress-short' : 'time'; + const nextItem = removeMetadataKeys(item, nextMode === 'time' + ? ['invert'] + : disabledInProgressKeys); const nextMetadata: Record = { - ...(item.metadata ?? {}), + ...(nextItem.metadata ?? {}), display: nextMode }; - if (nextMode === 'time') { - delete nextMetadata.invert; - } - return { - ...item, + ...nextItem, metadata: nextMetadata }; } export function toggleUsageInverted(item: WidgetItem): WidgetItem { return toggleMetadataFlag(item, 'invert'); +} + +export function getUsagePercentCustomKeybinds(item?: WidgetItem): CustomKeybind[] { + const keybinds = [PROGRESS_TOGGLE_KEYBIND]; + + if (item && isUsageProgressMode(getUsageDisplayMode(item))) { + keybinds.push(INVERT_TOGGLE_KEYBIND); + } + + return keybinds; +} + +export function getUsageTimerCustomKeybinds(item?: WidgetItem): CustomKeybind[] { + const keybinds = [PROGRESS_TOGGLE_KEYBIND]; + + if (item && isUsageProgressMode(getUsageDisplayMode(item))) { + keybinds.push(INVERT_TOGGLE_KEYBIND); + } else { + keybinds.push(COMPACT_TOGGLE_KEYBIND); + } + + return keybinds; } \ No newline at end of file