diff --git a/README.md b/README.md index 10c87fb..4a5e0a7 100644 --- a/README.md +++ b/README.md @@ -461,8 +461,8 @@ bun run example - **Memory Usage** - Shows system memory usage (used/total) - **Session Usage** - Shows daily/session API usage percentage - **Weekly Usage** - Shows weekly API usage percentage -- **Block Reset Timer** - Shows time remaining until current 5-hour block reset window -- **Weekly Reset Timer** - Shows time remaining until weekly usage reset +- **Block Reset Timer** - Shows time remaining until current 5-hour block reset window (or exact reset datetime in date mode) +- **Weekly Reset Timer** - Shows time remaining until weekly usage reset (or exact reset datetime in date mode) - **Context Bar** - Shows context usage as a progress bar with short/full display modes - **Skills** - Shows skill activity as last used, total count, or unique list (with optional list limit and hide-when-empty toggle) - **Separator** - Visual divider between widgets (available when Powerline mode is off and no default separator is configured) @@ -550,8 +550,8 @@ Widget-specific shortcuts: - **Git widgets**: `h` toggle hide `no git` output - **Context % widgets**: `u` toggle used vs remaining display - **Block Timer**: `p` cycle display mode (time/full bar/short bar) -- **Block Reset Timer**: `p` cycle display mode (time/full bar/short bar) -- **Weekly Reset Timer**: `p` cycle display mode (time/full bar/short bar) +- **Block Reset Timer**: `p` cycle display mode (time/full bar/short bar), `s` toggle compact time/date, `d` toggle exact reset datetime +- **Weekly Reset Timer**: `p` cycle display mode (time/full bar/short bar), `s` toggle compact time/date, `d` toggle exact reset datetime - **Current Working Dir**: `h` home abbreviation, `s` segment editor, `f` fish-style path - **Custom Command**: `e` command, `w` max width, `t` timeout, `p` preserve ANSI colors - **Link**: `u` URL, `e` link text diff --git a/src/utils/__tests__/usage.test.ts b/src/utils/__tests__/usage.test.ts index f96f42b..7595f7b 100644 --- a/src/utils/__tests__/usage.test.ts +++ b/src/utils/__tests__/usage.test.ts @@ -15,6 +15,7 @@ import { } from '../usage-types'; import { formatUsageDuration, + formatUsageResetAt, getUsageWindowFromResetAt, getWeeklyUsageWindowFromResetAt, resolveUsageWindowWithFallback, @@ -146,4 +147,14 @@ describe('usage window helpers', () => { expect(formatUsageDuration(3.5 * 60 * 60 * 1000, true)).toBe('3h30m'); expect(formatUsageDuration(4 * 60 * 60 * 1000 + 5 * 60 * 1000, true)).toBe('4h5m'); }); + + it('formats reset timestamps in UTC date mode', () => { + expect(formatUsageResetAt('2026-03-12T08:30:00.000Z')).toBe('2026-03-12 08:30 UTC'); + expect(formatUsageResetAt('2026-03-12T08:30:00.000Z', true)).toBe('03-12 08:30Z'); + }); + + it('returns null for invalid reset timestamps in date mode', () => { + expect(formatUsageResetAt(undefined)).toBeNull(); + expect(formatUsageResetAt('not-a-date')).toBeNull(); + }); }); \ No newline at end of file diff --git a/src/utils/usage-windows.ts b/src/utils/usage-windows.ts index 2d469c0..b6f7958 100644 --- a/src/utils/usage-windows.ts +++ b/src/utils/usage-windows.ts @@ -105,6 +105,32 @@ export function formatUsageDuration(durationMs: number, compact = false): string return `${elapsedHours}hr ${elapsedMinutes}m`; } +function pad(value: number): string { + return value.toString().padStart(2, '0'); +} + +export function formatUsageResetAt(resetAt: string | undefined, compact = false): string | null { + if (!resetAt) { + return null; + } + + const resetAtMs = Date.parse(resetAt); + if (Number.isNaN(resetAtMs)) { + return null; + } + + const date = new Date(resetAtMs); + const year = date.getUTCFullYear(); + const month = pad(date.getUTCMonth() + 1); + const day = pad(date.getUTCDate()); + const hours = pad(date.getUTCHours()); + const minutes = pad(date.getUTCMinutes()); + + return compact + ? `${month}-${day} ${hours}:${minutes}Z` + : `${year}-${month}-${day} ${hours}:${minutes} UTC`; +} + export function getUsageErrorMessage(error: UsageError): string { switch (error) { case 'no-credentials': return '[No credentials]'; diff --git a/src/utils/usage.ts b/src/utils/usage.ts index b67247a..827dd9d 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -1,6 +1,7 @@ export { fetchUsageData } from './usage-fetch'; export { formatUsageDuration, + formatUsageResetAt, getUsageErrorMessage, getUsageWindowFromBlockMetrics, getUsageWindowFromResetAt, diff --git a/src/widgets/BlockResetTimer.ts b/src/widgets/BlockResetTimer.ts index 4c23b92..b9afd8d 100644 --- a/src/widgets/BlockResetTimer.ts +++ b/src/widgets/BlockResetTimer.ts @@ -8,6 +8,7 @@ import type { } from '../types/Widget'; import { formatUsageDuration, + formatUsageResetAt, getUsageErrorMessage, resolveUsageWindowWithFallback } from '../utils/usage'; @@ -19,9 +20,11 @@ import { getUsageDisplayModifierText, getUsageProgressBarWidth, isUsageCompact, + isUsageDateMode, isUsageInverted, isUsageProgressMode, toggleUsageCompact, + toggleUsageDateMode, toggleUsageInverted } from './shared/usage-display'; @@ -41,7 +44,7 @@ export class BlockResetTimerWidget implements Widget { getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName(), - modifierText: getUsageDisplayModifierText(item, { includeCompact: true }) + modifierText: getUsageDisplayModifierText(item, { includeCompact: true, includeDate: true }) }; } @@ -58,6 +61,10 @@ export class BlockResetTimerWidget implements Widget { return toggleUsageCompact(item); } + if (action === 'toggle-date') { + return toggleUsageDateMode(item); + } + return null; } @@ -65,6 +72,7 @@ export class BlockResetTimerWidget implements Widget { const displayMode = getUsageDisplayMode(item); const inverted = isUsageInverted(item); const compact = isUsageCompact(item); + const dateMode = isUsageDateMode(item); if (context.isPreview) { const previewPercent = inverted ? 90.0 : 10.0; @@ -75,6 +83,10 @@ export class BlockResetTimerWidget implements Widget { return formatRawOrLabeledValue(item, 'Reset ', `[${progressBar}] ${previewPercent.toFixed(1)}%`); } + if (dateMode) { + return formatRawOrLabeledValue(item, 'Reset: ', compact ? '03-12 08:30Z' : '2026-03-12 08:30 UTC'); + } + return formatRawOrLabeledValue(item, 'Reset: ', compact ? '4h30m' : '4hr 30m'); } @@ -97,6 +109,13 @@ export class BlockResetTimerWidget implements Widget { return formatRawOrLabeledValue(item, 'Reset ', `[${progressBar}] ${percentage}%`); } + if (dateMode) { + const resetAt = formatUsageResetAt(usageData.sessionResetAt, compact); + if (resetAt) { + return formatRawOrLabeledValue(item, 'Reset: ', resetAt); + } + } + const remainingTime = formatUsageDuration(window.remainingMs, compact); return formatRawOrLabeledValue(item, 'Reset: ', remainingTime); } @@ -105,7 +124,8 @@ export class BlockResetTimerWidget implements Widget { 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' } + { key: 's', label: '(s)hort time', action: 'toggle-compact' }, + { key: 'd', label: '(d)ate mode', action: 'toggle-date' } ]; } diff --git a/src/widgets/WeeklyResetTimer.ts b/src/widgets/WeeklyResetTimer.ts index ead9af7..f89e61f 100644 --- a/src/widgets/WeeklyResetTimer.ts +++ b/src/widgets/WeeklyResetTimer.ts @@ -8,6 +8,7 @@ import type { } from '../types/Widget'; import { formatUsageDuration, + formatUsageResetAt, getUsageErrorMessage, resolveWeeklyUsageWindow } from '../utils/usage'; @@ -19,9 +20,11 @@ import { getUsageDisplayModifierText, getUsageProgressBarWidth, isUsageCompact, + isUsageDateMode, isUsageInverted, isUsageProgressMode, toggleUsageCompact, + toggleUsageDateMode, toggleUsageInverted } from './shared/usage-display'; @@ -41,7 +44,7 @@ export class WeeklyResetTimerWidget implements Widget { getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName(), - modifierText: getUsageDisplayModifierText(item, { includeCompact: true }) + modifierText: getUsageDisplayModifierText(item, { includeCompact: true, includeDate: true }) }; } @@ -58,6 +61,10 @@ export class WeeklyResetTimerWidget implements Widget { return toggleUsageCompact(item); } + if (action === 'toggle-date') { + return toggleUsageDateMode(item); + } + return null; } @@ -65,6 +72,7 @@ export class WeeklyResetTimerWidget implements Widget { const displayMode = getUsageDisplayMode(item); const inverted = isUsageInverted(item); const compact = isUsageCompact(item); + const dateMode = isUsageDateMode(item); if (context.isPreview) { const previewPercent = inverted ? 90.0 : 10.0; @@ -75,6 +83,10 @@ export class WeeklyResetTimerWidget implements Widget { return formatRawOrLabeledValue(item, 'Weekly Reset ', `[${progressBar}] ${previewPercent.toFixed(1)}%`); } + if (dateMode) { + return formatRawOrLabeledValue(item, 'Weekly Reset: ', compact ? '03-15 08:30Z' : '2026-03-15 08:30 UTC'); + } + return formatRawOrLabeledValue(item, 'Weekly Reset: ', compact ? '36h30m' : '36hr 30m'); } @@ -97,6 +109,13 @@ export class WeeklyResetTimerWidget implements Widget { return formatRawOrLabeledValue(item, 'Weekly Reset ', `[${progressBar}] ${percentage}%`); } + if (dateMode) { + const resetAt = formatUsageResetAt(usageData.weeklyResetAt, compact); + if (resetAt) { + return formatRawOrLabeledValue(item, 'Weekly Reset: ', resetAt); + } + } + const remainingTime = formatUsageDuration(window.remainingMs, compact); return formatRawOrLabeledValue(item, 'Weekly Reset: ', remainingTime); } @@ -105,7 +124,8 @@ export class WeeklyResetTimerWidget implements Widget { 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' } + { key: 's', label: '(s)hort time', action: 'toggle-compact' }, + { key: 'd', label: '(d)ate mode', action: 'toggle-date' } ]; } diff --git a/src/widgets/__tests__/BlockResetTimer.test.ts b/src/widgets/__tests__/BlockResetTimer.test.ts index 8b4bad1..afd5f84 100644 --- a/src/widgets/__tests__/BlockResetTimer.test.ts +++ b/src/widgets/__tests__/BlockResetTimer.test.ts @@ -22,12 +22,14 @@ function render(widget: BlockResetTimerWidget, item: WidgetItem, context: Render describe('BlockResetTimerWidget', () => { let mockFormatUsageDuration: { mockReturnValue: (value: string) => void }; + let mockFormatUsageResetAt: { mockReturnValue: (value: string | null) => void }; let mockGetUsageErrorMessage: { mockReturnValue: (value: string) => void }; let mockResolveUsageWindowWithFallback: { mockReturnValue: (value: UsageWindowMetrics | null) => void }; beforeEach(() => { vi.restoreAllMocks(); mockFormatUsageDuration = vi.spyOn(usage, 'formatUsageDuration'); + mockFormatUsageResetAt = vi.spyOn(usage, 'formatUsageResetAt'); mockGetUsageErrorMessage = vi.spyOn(usage, 'getUsageErrorMessage'); mockResolveUsageWindowWithFallback = vi.spyOn(usage, 'resolveUsageWindowWithFallback'); }); @@ -111,6 +113,24 @@ describe('BlockResetTimerWidget', () => { expect(render(widget, { id: 'reset', type: 'reset-timer', rawValue: true }, { usageData: {} })).toBe('3hr 45m'); }); + it('shows reset timestamp in date mode', () => { + const widget = new BlockResetTimerWidget(); + + mockResolveUsageWindowWithFallback.mockReturnValue({ + sessionDurationMs: 18000000, + elapsedMs: 4500000, + remainingMs: 13500000, + elapsedPercent: 25, + remainingPercent: 75 + }); + mockFormatUsageResetAt.mockReturnValue('2026-03-12 08:30 UTC'); + + expect(render(widget, + { id: 'reset', type: 'reset-timer', metadata: { absolute: 'true' } }, + { usageData: { sessionResetAt: '2026-03-12T08:30:00.000Z' } } + )).toBe('Reset: 2026-03-12 08:30 UTC'); + }); + runUsageTimerEditorSuite({ baseItem: { id: 'reset', type: 'reset-timer' }, createWidget: () => new BlockResetTimerWidget(), diff --git a/src/widgets/__tests__/WeeklyResetTimer.test.ts b/src/widgets/__tests__/WeeklyResetTimer.test.ts index 341095b..36c23d7 100644 --- a/src/widgets/__tests__/WeeklyResetTimer.test.ts +++ b/src/widgets/__tests__/WeeklyResetTimer.test.ts @@ -22,12 +22,14 @@ function render(widget: WeeklyResetTimerWidget, item: WidgetItem, context: Rende describe('WeeklyResetTimerWidget', () => { let mockFormatUsageDuration: { mockReturnValue: (value: string) => void }; + let mockFormatUsageResetAt: { mockReturnValue: (value: string | null) => void }; let mockGetUsageErrorMessage: { mockReturnValue: (value: string) => void }; let mockResolveWeeklyUsageWindow: { mockReturnValue: (value: UsageWindowMetrics | null) => void }; beforeEach(() => { vi.restoreAllMocks(); mockFormatUsageDuration = vi.spyOn(usage, 'formatUsageDuration'); + mockFormatUsageResetAt = vi.spyOn(usage, 'formatUsageResetAt'); mockGetUsageErrorMessage = vi.spyOn(usage, 'getUsageErrorMessage'); mockResolveWeeklyUsageWindow = vi.spyOn(usage, 'resolveWeeklyUsageWindow'); }); @@ -111,6 +113,24 @@ describe('WeeklyResetTimerWidget', () => { expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer', rawValue: true }, { usageData: {} })).toBe('120hr 15m'); }); + it('shows weekly reset timestamp in date mode', () => { + const widget = new WeeklyResetTimerWidget(); + + mockResolveWeeklyUsageWindow.mockReturnValue({ + sessionDurationMs: 604800000, + elapsedMs: 171900000, + remainingMs: 432900000, + elapsedPercent: 28.4216269841, + remainingPercent: 71.5783730159 + }); + mockFormatUsageResetAt.mockReturnValue('2026-03-15 08:30 UTC'); + + expect(render(widget, + { id: 'weekly-reset', type: 'weekly-reset-timer', metadata: { absolute: 'true' } }, + { usageData: { weeklyResetAt: '2026-03-15T08:30:00.000Z' } } + )).toBe('Weekly Reset: 2026-03-15 08:30 UTC'); + }); + runUsageTimerEditorSuite({ baseItem: { id: 'weekly-reset', type: 'weekly-reset-timer' }, createWidget: () => new WeeklyResetTimerWidget(), diff --git a/src/widgets/__tests__/helpers/usage-widget-suites.ts b/src/widgets/__tests__/helpers/usage-widget-suites.ts index 0e9c9af..ac0500f 100644 --- a/src/widgets/__tests__/helpers/usage-widget-suites.ts +++ b/src/widgets/__tests__/helpers/usage-widget-suites.ts @@ -53,7 +53,8 @@ const EXPECTED_USAGE_KEYBINDS: CustomKeybind[] = [ 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' } + { key: 's', label: '(s)hort time', action: 'toggle-compact' }, + { key: 'd', label: '(d)ate mode', action: 'toggle-date' } ]; function getUsageContext(field: 'sessionUsage' | 'weeklyUsage', value: number): RenderContext { @@ -226,4 +227,15 @@ export function runUsageTimerEditorSuite { + const widget = config.createWidget(); + + const dated = widget.handleEditorAction('toggle-date', config.baseItem); + const cleared = widget.handleEditorAction('toggle-date', dated ?? config.baseItem); + + expect(dated?.metadata?.absolute).toBe('true'); + expect(cleared?.metadata?.absolute).toBe('false'); + expect(widget.getEditorDisplay({ ...config.baseItem, metadata: { absolute: 'true' } }).modifierText).toBe('(date)'); + }); } \ No newline at end of file diff --git a/src/widgets/shared/usage-display.ts b/src/widgets/shared/usage-display.ts index 1c97e06..e7fd4bb 100644 --- a/src/widgets/shared/usage-display.ts +++ b/src/widgets/shared/usage-display.ts @@ -32,11 +32,22 @@ export function isUsageCompact(item: WidgetItem): boolean { return isMetadataFlagEnabled(item, 'compact'); } +export function isUsageDateMode(item: WidgetItem): boolean { + return isMetadataFlagEnabled(item, 'absolute'); +} + export function toggleUsageCompact(item: WidgetItem): WidgetItem { return toggleMetadataFlag(item, 'compact'); } -interface UsageDisplayModifierOptions { includeCompact?: boolean } +export function toggleUsageDateMode(item: WidgetItem): WidgetItem { + return toggleMetadataFlag(item, 'absolute'); +} + +interface UsageDisplayModifierOptions { + includeCompact?: boolean; + includeDate?: boolean; +} export function getUsageDisplayModifierText( item: WidgetItem, @@ -59,6 +70,10 @@ export function getUsageDisplayModifierText( modifiers.push('compact'); } + if (options.includeDate && isUsageDateMode(item)) { + modifiers.push('date'); + } + return makeModifierText(modifiers); }