mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-10-14 02:15:32 +00:00
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:
10 files changed
+173
-29
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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' }
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user