diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index 42815ab5..37fcd4a1 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -168,10 +168,10 @@ function renderPowerlineStatusLine( // Apply default padding from settings const padding = settings.defaultPadding ?? ''; - // If override FG color is set and this is a custom command with preserveColors, + // If override FG color is set and this widget has preserveColors, // we need to strip the ANSI codes from the widget text if (settings.overrideForegroundColor && settings.overrideForegroundColor !== 'none' - && widget.type === 'custom-command' && widget.preserveColors) { + && widget.preserveColors) { // Strip ANSI color codes when override is active widgetText = stripSgrCodes(widgetText); } @@ -191,8 +191,8 @@ function renderPowerlineStatusLine( let bgColor = widget.backgroundColor; // Apply theme colors if a theme is set (and not 'custom') - // For custom commands with preserveColors, only skip foreground theme colors - const skipFgTheme = widget.type === 'custom-command' && widget.preserveColors; + // For widgets with preserveColors, only skip foreground theme colors + const skipFgTheme = widget.preserveColors; if (themeColors) { if (!skipFgTheme) { @@ -303,8 +303,8 @@ function renderPowerlineStatusLine( let widgetContent = ''; - // For custom commands with preserveColors, only skip foreground color/bold - const isPreserveColors = widget.widget.type === 'custom-command' && widget.widget.preserveColors; + // For widgets with preserveColors, only skip foreground color/bold + const isPreserveColors = widget.widget.preserveColors; if (shouldBold && !isPreserveColors) { widgetContent += '\x1b[1m'; @@ -723,8 +723,8 @@ export function renderStatusLine( } if (widgetText) { - // Special handling for custom-command with preserveColors - if (widget.type === 'custom-command' && widget.preserveColors) { + // Special handling for widgets with preserveColors + if (widget.preserveColors) { // Handle max width truncation for commands with ANSI codes let finalOutput = widgetText; if (widget.maxWidth && widget.maxWidth > 0) { diff --git a/src/utils/widget-manifest.ts b/src/utils/widget-manifest.ts index 890abd9f..00adce64 100644 --- a/src/utils/widget-manifest.ts +++ b/src/utils/widget-manifest.ts @@ -54,7 +54,8 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [ { type: 'context-bar', create: () => new widgets.ContextBarWidget() }, { type: 'skills', create: () => new widgets.SkillsWidget() }, { type: 'thinking-effort', create: () => new widgets.ThinkingEffortWidget() }, - { type: 'vim-mode', create: () => new widgets.VimModeWidget() } + { type: 'vim-mode', create: () => new widgets.VimModeWidget() }, + { type: 'peak-hours', create: () => new widgets.PeakHoursWidget() } ]; export const LAYOUT_WIDGET_MANIFEST: LayoutWidgetManifestEntry[] = [ diff --git a/src/widgets/PeakHours.ts b/src/widgets/PeakHours.ts new file mode 100644 index 00000000..2ad1f4a1 --- /dev/null +++ b/src/widgets/PeakHours.ts @@ -0,0 +1,165 @@ +import { getColorLevelString } from '../types/ColorLevel'; +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { applyColors } from '../utils/colors'; +import { formatUsageDuration } from '../utils/usage'; + +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +import { + isUsageCompact, + toggleUsageCompact +} from './shared/usage-display'; + +const PEAK_START_HOUR = 5; +const PEAK_END_HOUR = 11; + +const WEEKDAY_MAP: Record = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }; + +const pacificFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: 'America/Los_Angeles', + weekday: 'short', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + hour12: false +}); + +interface PacificTimeParts { + weekday: number; // 0=Sun, 1=Mon, ..., 6=Sat + hour: number; + minute: number; + second: number; +} + +export function getPacificTimeParts(now: Date): PacificTimeParts { + const parts = pacificFormatter.formatToParts(now); + + let weekday = 0; + let hour = 0; + let minute = 0; + let second = 0; + + for (const part of parts) { + if (part.type === 'weekday') { + weekday = WEEKDAY_MAP[part.value] ?? 0; + } else if (part.type === 'hour') { + hour = parseInt(part.value, 10); + // Intl hour12:false can return 24 for midnight in some locales + if (hour === 24) + hour = 0; + } else if (part.type === 'minute') { + minute = parseInt(part.value, 10); + } else if (part.type === 'second') { + second = parseInt(part.value, 10); + } + } + + return { weekday, hour, minute, second }; +} + +export function isPeakHours(pt: PacificTimeParts): boolean { + const isWeekday = pt.weekday >= 1 && pt.weekday <= 5; + return isWeekday && pt.hour >= PEAK_START_HOUR && pt.hour < PEAK_END_HOUR; +} + +export function msUntilPeakEnds(pt: PacificTimeParts): number { + const hoursLeft = PEAK_END_HOUR - pt.hour - 1; + const minutesLeft = 59 - pt.minute; + const secondsLeft = 60 - pt.second; + return ((hoursLeft * 60 + minutesLeft) * 60 + secondsLeft) * 1000; +} + +export function msUntilNextPeakStarts(pt: PacificTimeParts): number { + const { weekday, hour, minute, second } = pt; + + const beforePeakToday = hour < PEAK_START_HOUR; + const isWeekday = weekday >= 1 && weekday <= 5; + + let daysUntil: number; + + if (isWeekday && beforePeakToday) { + daysUntil = 0; + } else if (weekday === 5) { + // Friday after peak → Monday + daysUntil = 3; + } else if (weekday === 6) { + // Saturday → Monday + daysUntil = 2; + } else if (weekday === 0) { + // Sunday → Monday + daysUntil = 1; + } else { + // Mon-Thu after peak → next day + daysUntil = 1; + } + + const hoursUntilTarget = daysUntil * 24 + (PEAK_START_HOUR - hour - 1); + const minutesLeft = 59 - minute; + const secondsLeft = 60 - second; + return ((hoursUntilTarget * 60 + minutesLeft) * 60 + secondsLeft) * 1000; +} + +export class PeakHoursWidget implements Widget { + // Empty string prevents the renderer from wrapping output in a default color, + // allowing the widget's embedded ANSI color codes (red/green) to take effect. + getDefaultColor(): string { return ''; } + getDescription(): string { return 'Shows peak hours status and countdown (weekdays 5am-11am PT)'; } + getDisplayName(): string { return 'Peak Hours'; } + getCategory(): string { return 'Usage'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const compact = isUsageCompact(item); + return { + displayText: this.getDisplayName(), + modifierText: compact ? 'compact' : undefined + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === 'toggle-compact') { + return toggleUsageCompact(item); + } + return null; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const compact = isUsageCompact(item); + const colorLevel = getColorLevelString(settings.colorLevel); + + if (context.isPreview) { + const peakText = formatRawOrLabeledValue(item, '\u26A0 Peak: ', compact ? '2h15m' : '2hr 15m'); + return applyColors(peakText, 'red', undefined, undefined, colorLevel); + } + + const now = new Date(); + const pt = getPacificTimeParts(now); + const peak = isPeakHours(pt); + + if (peak) { + const remaining = msUntilPeakEnds(pt); + const duration = formatUsageDuration(remaining, compact, false); + const text = formatRawOrLabeledValue(item, '\u26A0 Peak: ', duration); + return applyColors(text, 'red', undefined, undefined, colorLevel); + } + + const until = msUntilNextPeakStarts(pt); + const duration = formatUsageDuration(until, compact); + const text = formatRawOrLabeledValue(item, 'Peak in: ', duration); + return applyColors(text, 'green', undefined, undefined, colorLevel); + } + + getCustomKeybinds(): CustomKeybind[] { + return [ + { key: 's', label: 'Short time', action: 'toggle-compact' } + ]; + } + + supportsRawValue(): boolean { return true; } + supportsColors(_item: WidgetItem): boolean { return false; } +} \ No newline at end of file diff --git a/src/widgets/__tests__/PeakHours.test.ts b/src/widgets/__tests__/PeakHours.test.ts new file mode 100644 index 00000000..f5a5d6fa --- /dev/null +++ b/src/widgets/__tests__/PeakHours.test.ts @@ -0,0 +1,300 @@ +import chalk from 'chalk'; +import { + afterEach, + describe, + expect, + it +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { updateColorMap } from '../../utils/colors'; +import { + PeakHoursWidget, + getPacificTimeParts, + isPeakHours, + msUntilNextPeakStarts, + msUntilPeakEnds +} from '../PeakHours'; + +// Enable chalk colors in test environment +chalk.level = 2; +updateColorMap(); + +function render(widget: PeakHoursWidget, item: WidgetItem, context: RenderContext = {}): string | null { + return widget.render(item, context, DEFAULT_SETTINGS); +} + +function stripAnsi(text: string): string { + return text.replace(/\x1b\[[0-9;]*m/g, ''); +} + +function baseItem(): WidgetItem { + return { id: 'peak', type: 'peak-hours' }; +} + +function widget(): PeakHoursWidget { + return new PeakHoursWidget(); +} + +// Helper to mock Date constructor for render tests +const OrigDate = Date; + +function withFixedTime(isoString: string, fn: () => T): T { + const fixedMs = new OrigDate(isoString).getTime(); + const MockDate = class extends OrigDate { + constructor(value?: string | number | Date) { + if (value === undefined) { + super(fixedMs); + } else { + super(value as string); + } + } + + static override now(): number { return fixedMs; } + }; + globalThis.Date = MockDate as unknown as DateConstructor; + try { + return fn(); + } finally { + globalThis.Date = OrigDate; + } +} + +afterEach(() => { + globalThis.Date = OrigDate; + chalk.level = 2; +}); + +describe('PeakHoursWidget', () => { + describe('getPacificTimeParts', () => { + it('extracts correct parts for a weekday morning in PT (PST)', () => { + // Wednesday 2025-01-15 at 08:30:45 PT = 16:30:45 UTC (PST is UTC-8) + const pt = getPacificTimeParts(new Date('2025-01-15T16:30:45Z')); + expect(pt.weekday).toBe(3); // Wednesday + expect(pt.hour).toBe(8); + expect(pt.minute).toBe(30); + expect(pt.second).toBe(45); + }); + + it('handles PDT correctly in summer', () => { + // Wednesday 2025-07-16 at 08:30:00 PT = 15:30:00 UTC (PDT is UTC-7) + const pt = getPacificTimeParts(new Date('2025-07-16T15:30:00Z')); + expect(pt.weekday).toBe(3); // Wednesday + expect(pt.hour).toBe(8); + expect(pt.minute).toBe(30); + }); + + it('identifies Saturday correctly', () => { + const pt = getPacificTimeParts(new Date('2025-01-18T18:00:00Z')); + expect(pt.weekday).toBe(6); // Saturday + }); + + it('identifies Sunday correctly', () => { + const pt = getPacificTimeParts(new Date('2025-01-19T22:00:00Z')); + expect(pt.weekday).toBe(0); // Sunday + }); + }); + + describe('isPeakHours', () => { + it('returns true during peak hours on a weekday', () => { + expect(isPeakHours({ weekday: 1, hour: 5, minute: 0, second: 0 })).toBe(true); + expect(isPeakHours({ weekday: 3, hour: 8, minute: 30, second: 0 })).toBe(true); + expect(isPeakHours({ weekday: 5, hour: 10, minute: 59, second: 59 })).toBe(true); + }); + + it('returns false outside peak hours on a weekday', () => { + expect(isPeakHours({ weekday: 1, hour: 4, minute: 59, second: 59 })).toBe(false); + expect(isPeakHours({ weekday: 3, hour: 11, minute: 0, second: 0 })).toBe(false); + expect(isPeakHours({ weekday: 5, hour: 15, minute: 0, second: 0 })).toBe(false); + }); + + it('returns false on weekends even during peak time range', () => { + expect(isPeakHours({ weekday: 0, hour: 8, minute: 0, second: 0 })).toBe(false); + expect(isPeakHours({ weekday: 6, hour: 8, minute: 0, second: 0 })).toBe(false); + }); + }); + + describe('msUntilPeakEnds', () => { + it('calculates remaining time correctly at start of peak', () => { + const ms = msUntilPeakEnds({ weekday: 1, hour: 5, minute: 0, second: 0 }); + expect(ms).toBe(6 * 60 * 60 * 1000); + }); + + it('calculates remaining time correctly mid-peak', () => { + const ms = msUntilPeakEnds({ weekday: 3, hour: 8, minute: 30, second: 0 }); + expect(ms).toBe(2.5 * 60 * 60 * 1000); + }); + + it('calculates remaining time near end of peak', () => { + const ms = msUntilPeakEnds({ weekday: 5, hour: 10, minute: 55, second: 0 }); + expect(ms).toBe(5 * 60 * 1000); + }); + }); + + describe('msUntilNextPeakStarts', () => { + it('calculates time to next morning on weekday evening', () => { + // Monday 3pm → Tuesday 5am = 14 hours + const ms = msUntilNextPeakStarts({ weekday: 1, hour: 15, minute: 0, second: 0 }); + expect(ms).toBe(14 * 60 * 60 * 1000); + }); + + it('calculates time on weekday before peak starts', () => { + // Tuesday 3am → Tuesday 5am = 2 hours + const ms = msUntilNextPeakStarts({ weekday: 2, hour: 3, minute: 0, second: 0 }); + expect(ms).toBe(2 * 60 * 60 * 1000); + }); + + it('calculates time from Friday after peak to Monday', () => { + // Friday 11am → Monday 5am = 66 hours + const ms = msUntilNextPeakStarts({ weekday: 5, hour: 11, minute: 0, second: 0 }); + expect(ms).toBe(66 * 60 * 60 * 1000); + }); + + it('calculates time from Saturday to Monday', () => { + // Saturday 10am → Monday 5am = 43 hours + const ms = msUntilNextPeakStarts({ weekday: 6, hour: 10, minute: 0, second: 0 }); + expect(ms).toBe(43 * 60 * 60 * 1000); + }); + + it('calculates time from Sunday to Monday', () => { + // Sunday 10am → Monday 5am = 19 hours + const ms = msUntilNextPeakStarts({ weekday: 0, hour: 10, minute: 0, second: 0 }); + expect(ms).toBe(19 * 60 * 60 * 1000); + }); + + it('calculates time from Friday evening to Monday', () => { + // Friday 8pm → Monday 5am = 57 hours + const ms = msUntilNextPeakStarts({ weekday: 5, hour: 20, minute: 0, second: 0 }); + expect(ms).toBe(57 * 60 * 60 * 1000); + }); + }); + + describe('render', () => { + it('renders preview with red color and warning sign', () => { + const widget = new PeakHoursWidget(); + const result = render(widget, baseItem(), { isPreview: true }) ?? ''; + expect(result).not.toBe(''); + expect(stripAnsi(result)).toBe('\u26A0 Peak: 2hr 15m'); + expect(result).toContain('\x1b['); + }); + + it('renders compact preview', () => { + const widget = new PeakHoursWidget(); + const item: WidgetItem = { ...baseItem(), metadata: { compact: 'true' } }; + const result = render(widget, item, { isPreview: true }) ?? ''; + expect(stripAnsi(result)).toBe('\u26A0 Peak: 2h15m'); + }); + + it('renders during peak hours with warning sign', () => { + // Wednesday 8:30 AM PT = 16:30 UTC (PST) + const result = withFixedTime('2025-01-15T16:30:00Z', () => render(widget(), baseItem())) ?? ''; + expect(result).not.toBe(''); + const plain = stripAnsi(result); + expect(plain).toMatch(/^⚠ Peak: /); + expect(plain).toMatch(/2hr 30m/); + }); + + it('renders outside peak hours with countdown', () => { + // Wednesday 3:00 PM PT = 23:00 UTC (PST) + const result = withFixedTime('2025-01-15T23:00:00Z', () => render(widget(), baseItem())) ?? ''; + expect(result).not.toBe(''); + const plain = stripAnsi(result); + expect(plain).toMatch(/^Peak in: /); + }); + + it('renders raw value without label during peak', () => { + // Wednesday 8:30 AM PT + const item: WidgetItem = { ...baseItem(), rawValue: true }; + const result = withFixedTime('2025-01-15T16:30:00Z', () => render(widget(), item)) ?? ''; + const plain = stripAnsi(result); + expect(plain).toBe('2hr 30m'); + }); + + it('renders raw value without label outside peak', () => { + // Wednesday 3:00 PM PT + const item: WidgetItem = { ...baseItem(), rawValue: true }; + const result = withFixedTime('2025-01-15T23:00:00Z', () => render(widget(), item)) ?? ''; + const plain = stripAnsi(result); + expect(plain).not.toContain('Peak'); + }); + + it('renders weekend countdown to Monday', () => { + // Saturday 10:00 AM PT = 18:00 UTC (PST) + const result = withFixedTime('2025-01-18T18:00:00Z', () => render(widget(), baseItem())) ?? ''; + const plain = stripAnsi(result); + expect(plain).toMatch(/^Peak in: /); + expect(plain).toMatch(/1d 19hr/); + }); + + it('uses red color during peak hours', () => { + // Wednesday 8:30 AM PT + const result = withFixedTime('2025-01-15T16:30:00Z', () => render(widget(), baseItem())) ?? ''; + // ansi256 code 160 for red + expect(result).toContain('\x1b[38;5;160m'); + }); + + it('uses green color outside peak hours', () => { + // Wednesday 3:00 PM PT + const result = withFixedTime('2025-01-15T23:00:00Z', () => render(widget(), baseItem())) ?? ''; + // ansi256 code 70 for green + expect(result).toContain('\x1b[38;5;70m'); + }); + }); + + describe('widget interface', () => { + it('supportsColors returns false', () => { + const widget = new PeakHoursWidget(); + expect(widget.supportsColors({ id: 'peak', type: 'peak-hours' })).toBe(false); + }); + + it('supportsRawValue returns true', () => { + const widget = new PeakHoursWidget(); + expect(widget.supportsRawValue()).toBe(true); + }); + + it('getDefaultColor returns empty string to prevent renderer color wrapping', () => { + const widget = new PeakHoursWidget(); + expect(widget.getDefaultColor()).toBe(''); + }); + + it('getCategory returns Usage', () => { + const widget = new PeakHoursWidget(); + expect(widget.getCategory()).toBe('Usage'); + }); + + it('getDisplayName returns Peak Hours', () => { + const widget = new PeakHoursWidget(); + expect(widget.getDisplayName()).toBe('Peak Hours'); + }); + }); + + describe('editor', () => { + it('toggles compact mode', () => { + const widget = new PeakHoursWidget(); + const item = baseItem(); + const toggled = widget.handleEditorAction('toggle-compact', item); + expect(toggled).not.toBeNull(); + expect(toggled?.metadata?.compact).toBe('true'); + }); + + it('returns null for unknown action', () => { + const widget = new PeakHoursWidget(); + expect(widget.handleEditorAction('unknown', baseItem())).toBeNull(); + }); + + it('shows compact modifier text when compact', () => { + const widget = new PeakHoursWidget(); + const item: WidgetItem = { ...baseItem(), metadata: { compact: 'true' } }; + const display = widget.getEditorDisplay(item); + expect(display.modifierText).toBe('compact'); + }); + + it('shows no modifier text when not compact', () => { + const widget = new PeakHoursWidget(); + const display = widget.getEditorDisplay(baseItem()); + expect(display.modifierText).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 74d92bce..84df75c4 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -35,4 +35,5 @@ export { ContextBarWidget } from './ContextBar'; export { LinkWidget } from './Link'; export { SkillsWidget } from './Skills'; export { ThinkingEffortWidget } from './ThinkingEffort'; -export { VimModeWidget } from './VimMode'; \ No newline at end of file +export { VimModeWidget } from './VimMode'; +export { PeakHoursWidget } from './PeakHours'; \ No newline at end of file