feat(timer): add compact time format option for timer widgets (#203)

* feat(timer): add compact time format option for timer widgets

Add an optional compact display mode (s key) to BlockTimer, BlockResetTimer,
and WeeklyResetTimer that renders durations as '5h30m' instead of '5hr 30m'.

- Add `compact` boolean parameter to `formatUsageDuration`
- Add `isUsageCompact` / `toggleUsageCompact` helpers in usage-display.ts
- Add `toggle-compact` action and `s` keybind across all three timer widgets
- Preview text reflects compact format when flag is set
- Editor modifier text shows 'compact' when flag is enabled
- Tests cover compact format output, keybind exposure, and toggle behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix compact timer widget editor state

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-03-06 17:24:52 -05:00
1 parent 34fa512778
commit fe9cc15818
10 files changed
+173 -29

No files matched your search

+1 -12
View File
@@ -32,6 +32,7 @@ import {
type WidgetPickerAction,
type WidgetPickerState
} from './items-editor/input-handlers';
import { shouldShowCustomKeybind } from './items-editor/keybind-visibility';
export interface ItemsEditorProps {
widgets: WidgetItem[];
@@ -94,18 +95,6 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
setCustomEditorWidget(null);
};
const shouldShowCustomKeybind = (widget: WidgetItem, keybind: CustomKeybind): boolean => {
if (keybind.action !== 'toggle-invert') {
if (keybind.action === 'edit-list-limit') {
return widget.type === 'skills' && widget.metadata?.mode === 'list';
}
return true;
}
const mode = widget.metadata?.display;
return mode === 'progress' || mode === 'progress-short';
};
const getVisibleCustomKeybinds = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => {
if (!widgetImpl.getCustomKeybinds) {
return [];
@@ -0,0 +1,44 @@
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<string, string>): 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);
});
});
@@ -0,0 +1,25 @@
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;
}
+7
View File
@@ -139,4 +139,11 @@ describe('usage window helpers', () => {
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 in compact style', () => {
expect(formatUsageDuration(0, true)).toBe('0h');
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');
});
});
+5 -1
View File
@@ -89,11 +89,15 @@ export function resolveWeeklyUsageWindow(usageData: UsageData, nowMs = Date.now(
return getWeeklyUsageWindowFromResetAt(usageData.weeklyResetAt, nowMs);
}
export function formatUsageDuration(durationMs: number): string {
export function formatUsageDuration(durationMs: number, compact = false): 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`;
}
+12 -4
View File
@@ -18,8 +18,10 @@ import {
getUsageDisplayMode,
getUsageDisplayModifierText,
getUsageProgressBarWidth,
isUsageCompact,
isUsageInverted,
isUsageProgressMode,
toggleUsageCompact,
toggleUsageInverted
} from './shared/usage-display';
@@ -39,7 +41,7 @@ export class BlockResetTimerWidget implements Widget {
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
return {
displayText: this.getDisplayName(),
modifierText: getUsageDisplayModifierText(item)
modifierText: getUsageDisplayModifierText(item, { includeCompact: true })
};
}
@@ -52,12 +54,17 @@ export class BlockResetTimerWidget implements Widget {
return toggleUsageInverted(item);
}
if (action === 'toggle-compact') {
return toggleUsageCompact(item);
}
return null;
}
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
const displayMode = getUsageDisplayMode(item);
const inverted = isUsageInverted(item);
const compact = isUsageCompact(item);
if (context.isPreview) {
const previewPercent = inverted ? 90.0 : 10.0;
@@ -68,7 +75,7 @@ export class BlockResetTimerWidget implements Widget {
return formatRawOrLabeledValue(item, 'Reset ', `[${progressBar}] ${previewPercent.toFixed(1)}%`);
}
return formatRawOrLabeledValue(item, 'Reset: ', '4hr 30m');
return formatRawOrLabeledValue(item, 'Reset: ', compact ? '4h30m' : '4hr 30m');
}
const usageData = context.usageData ?? {};
@@ -90,14 +97,15 @@ export class BlockResetTimerWidget implements Widget {
return formatRawOrLabeledValue(item, 'Reset ', `[${progressBar}] ${percentage}%`);
}
const remainingTime = formatUsageDuration(window.remainingMs);
const remainingTime = formatUsageDuration(window.remainingMs, compact);
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: 'v', label: 'in(v)ert fill', action: 'toggle-invert' },
{ key: 's', label: '(s)hort time', action: 'toggle-compact' }
];
}
+13 -5
View File
@@ -17,8 +17,10 @@ import {
getUsageDisplayMode,
getUsageDisplayModifierText,
getUsageProgressBarWidth,
isUsageCompact,
isUsageInverted,
isUsageProgressMode,
toggleUsageCompact,
toggleUsageInverted
} from './shared/usage-display';
@@ -38,7 +40,7 @@ export class BlockTimerWidget implements Widget {
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
return {
displayText: this.getDisplayName(),
modifierText: getUsageDisplayModifierText(item)
modifierText: getUsageDisplayModifierText(item, { includeCompact: true })
};
}
@@ -51,12 +53,17 @@ export class BlockTimerWidget implements Widget {
return toggleUsageInverted(item);
}
if (action === 'toggle-compact') {
return toggleUsageCompact(item);
}
return null;
}
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
const displayMode = getUsageDisplayMode(item);
const inverted = isUsageInverted(item);
const compact = isUsageCompact(item);
if (context.isPreview) {
const previewPercent = inverted ? 26.1 : 73.9;
@@ -67,7 +74,7 @@ export class BlockTimerWidget implements Widget {
return formatRawOrLabeledValue(item, 'Block ', `[${progressBar}] ${previewPercent.toFixed(1)}%`);
}
return formatRawOrLabeledValue(item, 'Block: ', '3hr 45m');
return formatRawOrLabeledValue(item, 'Block: ', compact ? '3h45m' : '3hr 45m');
}
const usageData = context.usageData ?? {};
@@ -80,7 +87,7 @@ export class BlockTimerWidget implements Widget {
return formatRawOrLabeledValue(item, 'Block ', `[${emptyBar}] 0.0%`);
}
return formatRawOrLabeledValue(item, 'Block: ', '0hr 0m');
return formatRawOrLabeledValue(item, 'Block: ', compact ? '0h' : '0hr 0m');
}
if (isUsageProgressMode(displayMode)) {
@@ -91,14 +98,15 @@ export class BlockTimerWidget implements Widget {
return formatRawOrLabeledValue(item, 'Block ', `[${progressBar}] ${percentage}%`);
}
const elapsedTime = formatUsageDuration(window.elapsedMs);
const elapsedTime = formatUsageDuration(window.elapsedMs, compact);
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: 'v', label: 'in(v)ert fill', action: 'toggle-invert' },
{ key: 's', label: '(s)hort time', action: 'toggle-compact' }
];
}
+12 -4
View File
@@ -18,8 +18,10 @@ import {
getUsageDisplayMode,
getUsageDisplayModifierText,
getUsageProgressBarWidth,
isUsageCompact,
isUsageInverted,
isUsageProgressMode,
toggleUsageCompact,
toggleUsageInverted
} from './shared/usage-display';
@@ -39,7 +41,7 @@ export class WeeklyResetTimerWidget implements Widget {
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
return {
displayText: this.getDisplayName(),
modifierText: getUsageDisplayModifierText(item)
modifierText: getUsageDisplayModifierText(item, { includeCompact: true })
};
}
@@ -52,12 +54,17 @@ export class WeeklyResetTimerWidget implements Widget {
return toggleUsageInverted(item);
}
if (action === 'toggle-compact') {
return toggleUsageCompact(item);
}
return null;
}
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
const displayMode = getUsageDisplayMode(item);
const inverted = isUsageInverted(item);
const compact = isUsageCompact(item);
if (context.isPreview) {
const previewPercent = inverted ? 90.0 : 10.0;
@@ -68,7 +75,7 @@ export class WeeklyResetTimerWidget implements Widget {
return formatRawOrLabeledValue(item, 'Weekly Reset ', `[${progressBar}] ${previewPercent.toFixed(1)}%`);
}
return formatRawOrLabeledValue(item, 'Weekly Reset: ', '36hr 30m');
return formatRawOrLabeledValue(item, 'Weekly Reset: ', compact ? '36h30m' : '36hr 30m');
}
const usageData = context.usageData ?? {};
@@ -90,14 +97,15 @@ export class WeeklyResetTimerWidget implements Widget {
return formatRawOrLabeledValue(item, 'Weekly Reset ', `[${progressBar}] ${percentage}%`);
}
const remainingTime = formatUsageDuration(window.remainingMs);
const remainingTime = formatUsageDuration(window.remainingMs, compact);
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: 'v', label: 'in(v)ert fill', action: 'toggle-invert' },
{ key: 's', label: '(s)hort time', action: 'toggle-compact' }
];
}
@@ -50,6 +50,12 @@ const EXPECTED_USAGE_KEYBINDS: CustomKeybind[] = [
{ key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' }
];
const EXPECTED_TIMER_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' }
];
function getUsageContext(field: 'sessionUsage' | 'weeklyUsage', value: number): RenderContext {
return field === 'sessionUsage'
? { usageData: { sessionUsage: value } }
@@ -140,6 +146,23 @@ export function runUsagePercentWidgetSuite<TWidget extends UsageWidgetLike>(conf
expect(widget.getEditorDisplay(config.baseItem).modifierText).toBeUndefined();
expect(widget.getEditorDisplay(config.modifierItem).modifierText).toBe(config.expectedModifierText);
});
it('ignores stale compact metadata in editor modifiers', () => {
const widget = config.createWidget();
const modifierItemWithCompact: WidgetItem = {
...config.modifierItem,
metadata: {
...(config.modifierItem.metadata ?? {}),
compact: 'true'
}
};
expect(widget.getEditorDisplay({
...config.baseItem,
metadata: { compact: 'true' }
}).modifierText).toBeUndefined();
expect(widget.getEditorDisplay(modifierItemWithCompact).modifierText).toBe(config.expectedModifierText);
});
}
export function runUsageTimerEditorSuite<TWidget extends UsageWidgetLike & { getDisplayName(): string }>(config: UsageTimerEditorSuiteConfig<TWidget>): void {
@@ -147,12 +170,12 @@ export function runUsageTimerEditorSuite<TWidget extends UsageWidgetLike & { get
vi.clearAllMocks();
});
it('supports raw value and exposes progress/invert keybinds', () => {
it('supports raw value and exposes progress/invert/compact keybinds', () => {
const widget = config.createWidget();
expect(widget.getDisplayName()).toBe(config.expectedDisplayName);
expect(widget.supportsRawValue()).toBe(true);
expect(widget.getCustomKeybinds()).toEqual(EXPECTED_USAGE_KEYBINDS);
expect(widget.getCustomKeybinds()).toEqual(EXPECTED_TIMER_KEYBINDS);
});
it('clears invert metadata when cycling back to time mode', () => {
@@ -192,4 +215,15 @@ export function runUsageTimerEditorSuite<TWidget extends UsageWidgetLike & { get
expect(widget.getEditorDisplay(config.baseItem).modifierText).toBeUndefined();
expect(widget.getEditorDisplay(config.modifierItem).modifierText).toBe(config.expectedModifierText);
});
it('toggles compact metadata and shows compact modifier text', () => {
const widget = config.createWidget();
const compact = widget.handleEditorAction('toggle-compact', config.baseItem);
const cleared = widget.handleEditorAction('toggle-compact', compact ?? config.baseItem);
expect(compact?.metadata?.compact).toBe('true');
expect(cleared?.metadata?.compact).toBe('false');
expect(widget.getEditorDisplay({ ...config.baseItem, metadata: { compact: 'true' } }).modifierText).toBe('(compact)');
});
}
+18 -1
View File
@@ -28,7 +28,20 @@ export function isUsageInverted(item: WidgetItem): boolean {
return isMetadataFlagEnabled(item, 'invert');
}
export function getUsageDisplayModifierText(item: WidgetItem): string | undefined {
export function isUsageCompact(item: WidgetItem): boolean {
return isMetadataFlagEnabled(item, 'compact');
}
export function toggleUsageCompact(item: WidgetItem): WidgetItem {
return toggleMetadataFlag(item, 'compact');
}
interface UsageDisplayModifierOptions { includeCompact?: boolean }
export function getUsageDisplayModifierText(
item: WidgetItem,
options: UsageDisplayModifierOptions = {}
): string | undefined {
const mode = getUsageDisplayMode(item);
const modifiers: string[] = [];
@@ -42,6 +55,10 @@ export function getUsageDisplayModifierText(item: WidgetItem): string | undefine
modifiers.push('inverted');
}
if (options.includeCompact && isUsageCompact(item)) {
modifiers.push('compact');
}
return makeModifierText(modifiers);
}