diff --git a/README.md b/README.md index 3e22ebd..c089177 100644 --- a/README.md +++ b/README.md @@ -46,15 +46,16 @@ ## 🆕 Recent Updates -### v2.1.0 - v2.1.6 - Usage widgets, links, new git insertions / deletions widgets, and reliability fixes +### v2.1.0 - v2.1.7 - Usage widgets, links, new git insertions / deletions widgets, and reliability fixes -- **🧩 New Usage widgets (v2.1.0)** - Added **Session Usage**, **Weekly Usage**, **Reset Timer**, and **Context Bar** widgets. +- **🧩 New Usage widgets (v2.1.0)** - Added **Session Usage**, **Weekly Usage**, **Block Reset Timer**, and **Context Bar** widgets. - **📊 More accurate counts (v2.1.0)** - Usage/context widgets now use new statusline JSON metrics when available for more accurate token and context counts. - **🪟 Windows empty file bug fix (v2.1.1)** - Fixed a Windows issue that could create an empty `c:\dev\null` file. - **🔗 New Link widget (v2.1.3)** - Added a new **Link** widget with clickable OSC8 rendering, preview parity, and raw mode support. - **➕ New Git Insertions widget (v2.1.4)** - Added a dedicated Git widget that shows only uncommitted insertions (e.g., `+42`). - **➖ New Git Deletions widget (v2.1.4)** - Added a dedicated Git widget that shows only uncommitted deletions (e.g., `-10`). - **🧠 Context format fallback fix (v2.1.6)** - When `context_window_size` is missing, context widgets now infer 1M models from long-context labels such as `[1m]` and `1M context` in model identifiers. +- **⏳ Weekly reset timer split (v2.1.7)** - Added a separate `Weekly Reset Timer` widget. ### v2.0.26 - v2.0.29 - Performance, git internals, and workflow improvements @@ -426,7 +427,8 @@ bun run example - **Session Name** - Shows the session name set via `/rename` command in Claude Code - **Claude Session ID** - Shows the current Claude Code session ID from status JSON - **Block Timer** - Shows time elapsed in current 5-hour block or progress bar -- **Reset Timer** - Shows time remaining until the current 5-hour block resets +- **Block Reset Timer** - Shows time remaining until the current 5-hour block resets +- **Weekly Reset Timer** - Shows time remaining until the weekly usage window resets - **Current Working Directory** - Shows current working directory with segment limit, fish-style abbreviation, and optional `~` home abbreviation - **Version** - Shows Claude Code version - **Output Style** - Shows the currently set output style in Claude Code @@ -528,6 +530,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) - **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/package.json b/package.json index 46528b8..46314ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccstatusline", - "version": "2.1.6", + "version": "2.1.7", "description": "A customizable status line formatter for Claude Code CLI", "module": "src/ccstatusline.ts", "type": "module", diff --git a/src/utils/__tests__/usage.test.ts b/src/utils/__tests__/usage.test.ts index 2901ad3..feab42a 100644 --- a/src/utils/__tests__/usage.test.ts +++ b/src/utils/__tests__/usage.test.ts @@ -10,9 +10,12 @@ import type { BlockMetrics } from '../../types'; import { getCachedBlockMetrics } from '../jsonl'; import { FIVE_HOUR_BLOCK_MS, + SEVEN_DAY_WINDOW_MS, formatUsageDuration, getUsageWindowFromResetAt, - resolveUsageWindowWithFallback + getWeeklyUsageWindowFromResetAt, + resolveUsageWindowWithFallback, + resolveWeeklyUsageWindow } from '../usage'; vi.mock('../jsonl', () => ({ getCachedBlockMetrics: vi.fn() })); @@ -96,6 +99,34 @@ describe('usage window helpers', () => { expect(mockGetCachedBlockMetrics.mock.calls.length).toBe(1); }); + it('parses weekly reset timestamp into elapsed and remaining metrics', () => { + const nowMs = Date.parse('2026-03-04T20:00:00.000Z'); + const resetAt = '2026-03-09T20:00:00.000Z'; + + const window = getWeeklyUsageWindowFromResetAt(resetAt, nowMs); + + expect(window).not.toBeNull(); + expect(window?.elapsedMs).toBe(2 * 24 * 60 * 60 * 1000); + expect(window?.remainingMs).toBe(5 * 24 * 60 * 60 * 1000); + expect(window?.elapsedPercent).toBeCloseTo((2 / 7) * 100, 5); + expect(window?.remainingPercent).toBeCloseTo((5 / 7) * 100, 5); + expect(window?.sessionDurationMs).toBe(SEVEN_DAY_WINDOW_MS); + }); + + it('returns null for missing or invalid weekly reset timestamps', () => { + expect(getWeeklyUsageWindowFromResetAt(undefined, Date.now())).toBeNull(); + expect(getWeeklyUsageWindowFromResetAt('not-a-date', Date.now())).toBeNull(); + }); + + it('resolves weekly window directly from usage data without JSONL fallback', () => { + const nowMs = Date.parse('2026-03-04T20:00:00.000Z'); + const window = resolveWeeklyUsageWindow({ weeklyResetAt: '2026-03-09T20:00:00.000Z' }, nowMs); + + expect(window).not.toBeNull(); + expect(window?.remainingMs).toBe(5 * 24 * 60 * 60 * 1000); + expect(mockGetCachedBlockMetrics.mock.calls.length).toBe(0); + }); + it('formats duration in block timer style', () => { expect(formatUsageDuration(0)).toBe('0hr'); expect(formatUsageDuration(3 * 60 * 60 * 1000)).toBe('3hr'); diff --git a/src/utils/__tests__/widgets.test.ts b/src/utils/__tests__/widgets.test.ts index 076ab89..eb809b9 100644 --- a/src/utils/__tests__/widgets.test.ts +++ b/src/utils/__tests__/widgets.test.ts @@ -30,6 +30,8 @@ describe('widget catalog', () => { const link = catalog.find(entry => entry.type === 'link'); const gitInsertions = catalog.find(entry => entry.type === 'git-insertions'); const gitDeletions = catalog.find(entry => entry.type === 'git-deletions'); + const resetTimer = catalog.find(entry => entry.type === 'reset-timer'); + const weeklyResetTimer = catalog.find(entry => entry.type === 'weekly-reset-timer'); expect(model?.displayName).toBe('Model'); expect(model?.category).toBe('Core'); @@ -41,6 +43,10 @@ describe('widget catalog', () => { expect(gitInsertions?.category).toBe('Git'); expect(gitDeletions?.displayName).toBe('Git Deletions'); expect(gitDeletions?.category).toBe('Git'); + expect(resetTimer?.displayName).toBe('Block Reset Timer'); + expect(resetTimer?.category).toBe('Usage'); + expect(weeklyResetTimer?.displayName).toBe('Weekly Reset Timer'); + expect(weeklyResetTimer?.category).toBe('Usage'); }); it('hides manual separator when default separator is configured', () => { diff --git a/src/utils/usage.ts b/src/utils/usage.ts index c8aaa6d..bd9ffc5 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -20,6 +20,7 @@ const CACHE_MAX_AGE = 180; // seconds const LOCK_MAX_AGE = 30; // rate limit: only try API once per 30 seconds const TOKEN_CACHE_MAX_AGE = 3600; // 1 hour export const FIVE_HOUR_BLOCK_MS = 5 * 60 * 60 * 1000; +export const SEVEN_DAY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; // Error types matching shell script const UsageErrorSchema = z.enum(['no-credentials', 'timeout', 'api-error', 'parse-error']); @@ -27,8 +28,9 @@ export type UsageError = z.infer; export interface UsageData { sessionUsage?: number; // five_hour.utilization (percentage) - sessionResetAt?: string; // five_hour.reset_at + sessionResetAt?: string; // five_hour.resets_at weeklyUsage?: number; // seven_day.utilization (percentage) + weeklyResetAt?: string; // seven_day.resets_at extraUsageEnabled?: boolean; extraUsageLimit?: number; // in cents extraUsageUsed?: number; // in cents @@ -50,6 +52,7 @@ const CachedUsageDataSchema = z.object({ sessionUsage: z.number().nullable().optional(), sessionResetAt: z.string().nullable().optional(), weeklyUsage: z.number().nullable().optional(), + weeklyResetAt: z.string().nullable().optional(), extraUsageEnabled: z.boolean().nullable().optional(), extraUsageLimit: z.number().nullable().optional(), extraUsageUsed: z.number().nullable().optional(), @@ -60,10 +63,12 @@ const CachedUsageDataSchema = z.object({ const UsageApiResponseSchema = z.object({ five_hour: z.object({ utilization: z.number().nullable().optional(), - reset_at: z.string().nullable().optional(), resets_at: z.string().nullable().optional() }).optional(), - seven_day: z.object({ utilization: z.number().nullable().optional() }).optional(), + seven_day: z.object({ + utilization: z.number().nullable().optional(), + resets_at: z.string().nullable().optional() + }).optional(), extra_usage: z.object({ is_enabled: z.boolean().nullable().optional(), monthly_limit: z.number().nullable().optional(), @@ -98,6 +103,7 @@ function parseCachedUsageData(rawJson: string): UsageData | null { sessionUsage: parsed.sessionUsage ?? undefined, sessionResetAt: parsed.sessionResetAt ?? undefined, weeklyUsage: parsed.weeklyUsage ?? undefined, + weeklyResetAt: parsed.weeklyResetAt ?? undefined, extraUsageEnabled: parsed.extraUsageEnabled ?? undefined, extraUsageLimit: parsed.extraUsageLimit ?? undefined, extraUsageUsed: parsed.extraUsageUsed ?? undefined, @@ -114,8 +120,9 @@ function parseUsageApiResponse(rawJson: string): UsageData | null { return { sessionUsage: parsed.five_hour?.utilization ?? undefined, - sessionResetAt: parsed.five_hour?.resets_at ?? parsed.five_hour?.reset_at ?? undefined, + sessionResetAt: parsed.five_hour?.resets_at ?? undefined, weeklyUsage: parsed.seven_day?.utilization ?? undefined, + weeklyResetAt: parsed.seven_day?.resets_at ?? undefined, extraUsageEnabled: parsed.extra_usage?.is_enabled ?? undefined, extraUsageLimit: parsed.extra_usage?.monthly_limit ?? undefined, extraUsageUsed: parsed.extra_usage?.used_credits ?? undefined, @@ -347,18 +354,18 @@ function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } -function buildUsageWindow(resetAtMs: number, nowMs: number): UsageWindowMetrics | null { - if (!Number.isFinite(resetAtMs) || !Number.isFinite(nowMs)) { +function buildUsageWindow(resetAtMs: number, nowMs: number, durationMs: number): UsageWindowMetrics | null { + if (!Number.isFinite(resetAtMs) || !Number.isFinite(nowMs) || !Number.isFinite(durationMs) || durationMs <= 0) { return null; } - const startAtMs = resetAtMs - FIVE_HOUR_BLOCK_MS; - const elapsedMs = clamp(nowMs - startAtMs, 0, FIVE_HOUR_BLOCK_MS); - const remainingMs = FIVE_HOUR_BLOCK_MS - elapsedMs; - const elapsedPercent = (elapsedMs / FIVE_HOUR_BLOCK_MS) * 100; + const startAtMs = resetAtMs - durationMs; + const elapsedMs = clamp(nowMs - startAtMs, 0, durationMs); + const remainingMs = durationMs - elapsedMs; + const elapsedPercent = (elapsedMs / durationMs) * 100; return { - sessionDurationMs: FIVE_HOUR_BLOCK_MS, + sessionDurationMs: durationMs, elapsedMs, remainingMs, elapsedPercent, @@ -376,7 +383,7 @@ export function getUsageWindowFromResetAt(sessionResetAt: string | undefined, no return null; } - return buildUsageWindow(resetAtMs, nowMs); + return buildUsageWindow(resetAtMs, nowMs, FIVE_HOUR_BLOCK_MS); } export function getUsageWindowFromBlockMetrics(blockMetrics: BlockMetrics, nowMs = Date.now()): UsageWindowMetrics | null { @@ -385,7 +392,7 @@ export function getUsageWindowFromBlockMetrics(blockMetrics: BlockMetrics, nowMs return null; } - return buildUsageWindow(startAtMs + FIVE_HOUR_BLOCK_MS, nowMs); + return buildUsageWindow(startAtMs + FIVE_HOUR_BLOCK_MS, nowMs, FIVE_HOUR_BLOCK_MS); } export function resolveUsageWindowWithFallback( @@ -406,6 +413,23 @@ export function resolveUsageWindowWithFallback( return getUsageWindowFromBlockMetrics(fallbackMetrics, nowMs); } +export function getWeeklyUsageWindowFromResetAt(weeklyResetAt: string | undefined, nowMs = Date.now()): UsageWindowMetrics | null { + if (!weeklyResetAt) { + return null; + } + + const resetAtMs = Date.parse(weeklyResetAt); + if (Number.isNaN(resetAtMs)) { + return null; + } + + return buildUsageWindow(resetAtMs, nowMs, SEVEN_DAY_WINDOW_MS); +} + +export function resolveWeeklyUsageWindow(usageData: UsageData, nowMs = Date.now()): UsageWindowMetrics | null { + return getWeeklyUsageWindowFromResetAt(usageData.weeklyResetAt, nowMs); +} + export function formatUsageDuration(durationMs: number): string { const clampedMs = Math.max(0, durationMs); const elapsedHours = Math.floor(clampedMs / (1000 * 60 * 60)); diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts index 3b6ad36..05d5a7c 100644 --- a/src/utils/widgets.ts +++ b/src/utils/widgets.ts @@ -36,7 +36,8 @@ const widgetRegistry = new Map([ ['free-memory', new widgets.FreeMemoryWidget()], ['session-usage', new widgets.SessionUsageWidget()], ['weekly-usage', new widgets.WeeklyUsageWidget()], - ['reset-timer', new widgets.ResetTimerWidget()], + ['reset-timer', new widgets.BlockResetTimerWidget()], + ['weekly-reset-timer', new widgets.WeeklyResetTimerWidget()], ['context-bar', new widgets.ContextBarWidget()] ]); diff --git a/src/widgets/ResetTimer.ts b/src/widgets/BlockResetTimer.ts similarity index 95% rename from src/widgets/ResetTimer.ts rename to src/widgets/BlockResetTimer.ts index 79ea6bb..86b2e38 100644 --- a/src/widgets/ResetTimer.ts +++ b/src/widgets/BlockResetTimer.ts @@ -31,10 +31,10 @@ function makeTimerProgressBar(percent: number, width: number): string { return '█'.repeat(filledWidth) + '░'.repeat(emptyWidth); } -export class ResetTimerWidget implements Widget { +export class BlockResetTimerWidget implements Widget { getDefaultColor(): string { return 'brightBlue'; } - getDescription(): string { return 'Shows time remaining until current 5hr block reset'; } - getDisplayName(): string { return 'Reset Timer'; } + getDescription(): string { return 'Shows time remaining until current 5hr block reset window'; } + getDisplayName(): string { return 'Block Reset Timer'; } getCategory(): string { return 'Usage'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { diff --git a/src/widgets/WeeklyResetTimer.ts b/src/widgets/WeeklyResetTimer.ts new file mode 100644 index 0000000..6d7a158 --- /dev/null +++ b/src/widgets/WeeklyResetTimer.ts @@ -0,0 +1,107 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + fetchUsageData, + formatUsageDuration, + getUsageErrorMessage, + resolveWeeklyUsageWindow +} from '../utils/usage'; + +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +import { + cycleUsageDisplayMode, + getUsageDisplayMode, + getUsageDisplayModifierText, + getUsageProgressBarWidth, + isUsageInverted, + isUsageProgressMode, + toggleUsageInverted +} from './shared/usage-display'; + +function makeTimerProgressBar(percent: number, width: number): string { + const clampedPercent = Math.max(0, Math.min(100, percent)); + const filledWidth = Math.floor((clampedPercent / 100) * width); + const emptyWidth = width - filledWidth; + return '█'.repeat(filledWidth) + '░'.repeat(emptyWidth); +} + +export class WeeklyResetTimerWidget implements Widget { + getDefaultColor(): string { return 'brightBlue'; } + getDescription(): string { return 'Shows time remaining until weekly usage reset'; } + getDisplayName(): string { return 'Weekly Reset Timer'; } + getCategory(): string { return 'Usage'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getUsageDisplayModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === 'toggle-progress') { + return cycleUsageDisplayMode(item); + } + + if (action === 'toggle-invert') { + return toggleUsageInverted(item); + } + + return null; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const displayMode = getUsageDisplayMode(item); + const inverted = isUsageInverted(item); + + if (context.isPreview) { + const previewPercent = inverted ? 90.0 : 10.0; + + if (isUsageProgressMode(displayMode)) { + const barWidth = getUsageProgressBarWidth(displayMode); + const progressBar = makeTimerProgressBar(previewPercent, barWidth); + return formatRawOrLabeledValue(item, 'Weekly Reset ', `[${progressBar}] ${previewPercent.toFixed(1)}%`); + } + + return formatRawOrLabeledValue(item, 'Weekly Reset: ', '36hr 30m'); + } + + const usageData = fetchUsageData(); + const window = resolveWeeklyUsageWindow(usageData); + + if (!window) { + if (usageData.error) { + return getUsageErrorMessage(usageData.error); + } + + return null; + } + + if (isUsageProgressMode(displayMode)) { + const barWidth = getUsageProgressBarWidth(displayMode); + const percent = inverted ? window.remainingPercent : window.elapsedPercent; + const progressBar = makeTimerProgressBar(percent, barWidth); + const percentage = percent.toFixed(1); + return formatRawOrLabeledValue(item, 'Weekly Reset ', `[${progressBar}] ${percentage}%`); + } + + const remainingTime = formatUsageDuration(window.remainingMs); + 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' } + ]; + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/__tests__/ResetTimer.test.ts b/src/widgets/__tests__/BlockResetTimer.test.ts similarity index 87% rename from src/widgets/__tests__/ResetTimer.test.ts rename to src/widgets/__tests__/BlockResetTimer.test.ts index 1099652..d2c3990 100644 --- a/src/widgets/__tests__/ResetTimer.test.ts +++ b/src/widgets/__tests__/BlockResetTimer.test.ts @@ -15,7 +15,7 @@ import { getUsageErrorMessage, resolveUsageWindowWithFallback } from '../../utils/usage'; -import { ResetTimerWidget } from '../ResetTimer'; +import { BlockResetTimerWidget } from '../BlockResetTimer'; vi.mock('../../utils/usage', () => ({ fetchUsageData: vi.fn(), @@ -29,18 +29,19 @@ const mockFormatUsageDuration = formatUsageDuration as unknown as { mockReturnVa const mockGetUsageErrorMessage = getUsageErrorMessage as unknown as { mockReturnValue: (value: string) => void }; const mockResolveUsageWindowWithFallback = resolveUsageWindowWithFallback as unknown as { mockReturnValue: (value: unknown) => void }; -function render(widget: ResetTimerWidget, item: WidgetItem, context: RenderContext = {}): string | null { +function render(widget: BlockResetTimerWidget, item: WidgetItem, context: RenderContext = {}): string | null { return widget.render(item, context, DEFAULT_SETTINGS); } -describe('ResetTimerWidget', () => { +describe('BlockResetTimerWidget', () => { beforeEach(() => { vi.clearAllMocks(); }); it('supports raw value and exposes progress/invert keybinds', () => { - const widget = new ResetTimerWidget(); + const widget = new BlockResetTimerWidget(); + expect(widget.getDisplayName()).toBe('Block Reset Timer'); expect(widget.supportsRawValue()).toBe(true); expect(widget.getCustomKeybinds()).toEqual([ { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, @@ -49,13 +50,13 @@ describe('ResetTimerWidget', () => { }); it('renders preview using block-style reset format', () => { - const widget = new ResetTimerWidget(); + const widget = new BlockResetTimerWidget(); expect(render(widget, { id: 'reset', type: 'reset-timer' }, { isPreview: true })).toBe('Reset: 4hr 30m'); }); it('renders remaining time in time mode', () => { - const widget = new ResetTimerWidget(); + const widget = new BlockResetTimerWidget(); mockFetchUsageData.mockReturnValue({}); mockResolveUsageWindowWithFallback.mockReturnValue({ @@ -71,7 +72,7 @@ describe('ResetTimerWidget', () => { }); it('renders short progress bar with inverted fill', () => { - const widget = new ResetTimerWidget(); + const widget = new BlockResetTimerWidget(); const item: WidgetItem = { id: 'reset', type: 'reset-timer', @@ -94,7 +95,7 @@ describe('ResetTimerWidget', () => { }); it('returns usage error when no timer data is available', () => { - const widget = new ResetTimerWidget(); + const widget = new BlockResetTimerWidget(); mockFetchUsageData.mockReturnValue({ error: 'timeout' }); mockResolveUsageWindowWithFallback.mockReturnValue(null); @@ -104,7 +105,7 @@ describe('ResetTimerWidget', () => { }); it('returns null when neither timer data nor usage error exists', () => { - const widget = new ResetTimerWidget(); + const widget = new BlockResetTimerWidget(); mockFetchUsageData.mockReturnValue({}); mockResolveUsageWindowWithFallback.mockReturnValue(null); @@ -113,7 +114,7 @@ describe('ResetTimerWidget', () => { }); it('shows raw value without label in time mode', () => { - const widget = new ResetTimerWidget(); + const widget = new BlockResetTimerWidget(); mockFetchUsageData.mockReturnValue({}); mockResolveUsageWindowWithFallback.mockReturnValue({ @@ -129,7 +130,7 @@ describe('ResetTimerWidget', () => { }); it('clears invert metadata when cycling back to time mode', () => { - const widget = new ResetTimerWidget(); + const widget = new BlockResetTimerWidget(); const updated = widget.handleEditorAction('toggle-progress', { id: 'reset', type: 'reset-timer', @@ -144,7 +145,7 @@ describe('ResetTimerWidget', () => { }); it('cycles display modes in the expected order', () => { - const widget = new ResetTimerWidget(); + const widget = new BlockResetTimerWidget(); const base: WidgetItem = { id: 'reset', type: 'reset-timer' }; const first = widget.handleEditorAction('toggle-progress', base); @@ -157,7 +158,7 @@ describe('ResetTimerWidget', () => { }); it('toggles invert metadata and shows editor modifiers', () => { - const widget = new ResetTimerWidget(); + const widget = new BlockResetTimerWidget(); const base: WidgetItem = { id: 'reset', type: 'reset-timer' }; const inverted = widget.handleEditorAction('toggle-invert', base); diff --git a/src/widgets/__tests__/WeeklyResetTimer.test.ts b/src/widgets/__tests__/WeeklyResetTimer.test.ts new file mode 100644 index 0000000..c4b3d5a --- /dev/null +++ b/src/widgets/__tests__/WeeklyResetTimer.test.ts @@ -0,0 +1,175 @@ +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { + fetchUsageData, + formatUsageDuration, + getUsageErrorMessage, + resolveWeeklyUsageWindow +} from '../../utils/usage'; +import { WeeklyResetTimerWidget } from '../WeeklyResetTimer'; + +vi.mock('../../utils/usage', () => ({ + fetchUsageData: vi.fn(), + formatUsageDuration: vi.fn(), + getUsageErrorMessage: vi.fn(), + resolveWeeklyUsageWindow: vi.fn() +})); + +const mockFetchUsageData = fetchUsageData as unknown as { mockReturnValue: (value: unknown) => void }; +const mockFormatUsageDuration = formatUsageDuration as unknown as { mockReturnValue: (value: string) => void }; +const mockGetUsageErrorMessage = getUsageErrorMessage as unknown as { mockReturnValue: (value: string) => void }; +const mockResolveWeeklyUsageWindow = resolveWeeklyUsageWindow as unknown as { mockReturnValue: (value: unknown) => void }; + +function render(widget: WeeklyResetTimerWidget, item: WidgetItem, context: RenderContext = {}): string | null { + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('WeeklyResetTimerWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('supports raw value and exposes progress/invert keybinds', () => { + const widget = new WeeklyResetTimerWidget(); + + expect(widget.getDisplayName()).toBe('Weekly Reset Timer'); + expect(widget.supportsRawValue()).toBe(true); + expect(widget.getCustomKeybinds()).toEqual([ + { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, + { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' } + ]); + }); + + 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'); + }); + + it('renders remaining time in time mode', () => { + const widget = new WeeklyResetTimerWidget(); + + mockFetchUsageData.mockReturnValue({}); + 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' })).toBe('Weekly Reset: 134hr 40m'); + }); + + it('renders short progress bar with inverted fill', () => { + const widget = new WeeklyResetTimerWidget(); + const item: WidgetItem = { + id: 'weekly-reset', + type: 'weekly-reset-timer', + metadata: { + display: 'progress-short', + invert: 'true' + } + }; + + mockFetchUsageData.mockReturnValue({}); + mockResolveWeeklyUsageWindow.mockReturnValue({ + sessionDurationMs: 604800000, + elapsedMs: 483840000, + remainingMs: 120960000, + elapsedPercent: 80, + remainingPercent: 20 + }); + + expect(render(widget, item)).toBe('Weekly Reset [███░░░░░░░░░░░░░] 20.0%'); + }); + + it('returns usage error when no weekly reset data is available', () => { + const widget = new WeeklyResetTimerWidget(); + + mockFetchUsageData.mockReturnValue({ error: 'timeout' }); + mockResolveWeeklyUsageWindow.mockReturnValue(null); + mockGetUsageErrorMessage.mockReturnValue('[Timeout]'); + + expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer' })).toBe('[Timeout]'); + }); + + it('returns null when neither weekly reset data nor usage error exists', () => { + const widget = new WeeklyResetTimerWidget(); + + mockFetchUsageData.mockReturnValue({}); + mockResolveWeeklyUsageWindow.mockReturnValue(null); + + expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer' })).toBeNull(); + }); + + it('shows raw value without label in time mode', () => { + const widget = new WeeklyResetTimerWidget(); + + mockFetchUsageData.mockReturnValue({}); + mockResolveWeeklyUsageWindow.mockReturnValue({ + sessionDurationMs: 604800000, + elapsedMs: 171900000, + remainingMs: 432900000, + elapsedPercent: 28.4216269841, + remainingPercent: 71.5783730159 + }); + mockFormatUsageDuration.mockReturnValue('120hr 15m'); + + expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer', rawValue: true })).toBe('120hr 15m'); + }); + + it('clears invert metadata when cycling back to time mode', () => { + const widget = new WeeklyResetTimerWidget(); + const updated = widget.handleEditorAction('toggle-progress', { + id: 'weekly-reset', + type: 'weekly-reset-timer', + metadata: { + display: 'progress-short', + invert: 'true' + } + }); + + expect(updated?.metadata?.display).toBe('time'); + expect(updated?.metadata?.invert).toBeUndefined(); + }); + + it('cycles display modes in the expected order', () => { + const widget = new WeeklyResetTimerWidget(); + const base: WidgetItem = { id: 'weekly-reset', type: 'weekly-reset-timer' }; + + const first = widget.handleEditorAction('toggle-progress', base); + const second = widget.handleEditorAction('toggle-progress', first ?? base); + const third = widget.handleEditorAction('toggle-progress', second ?? base); + + expect(first?.metadata?.display).toBe('progress'); + expect(second?.metadata?.display).toBe('progress-short'); + expect(third?.metadata?.display).toBe('time'); + }); + + it('toggles invert metadata and shows editor modifiers', () => { + const widget = new WeeklyResetTimerWidget(); + const base: WidgetItem = { id: 'weekly-reset', type: 'weekly-reset-timer' }; + + const inverted = widget.handleEditorAction('toggle-invert', base); + const cleared = widget.handleEditorAction('toggle-invert', inverted ?? base); + + expect(inverted?.metadata?.invert).toBe('true'); + expect(cleared?.metadata?.invert).toBe('false'); + expect(widget.getEditorDisplay(base).modifierText).toBeUndefined(); + expect(widget.getEditorDisplay({ + ...base, + metadata: { display: 'progress-short', invert: 'true' } + }).modifierText).toBe('(short bar, inverted)'); + }); +}); \ No newline at end of file diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 849dc1b..e0738ba 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -26,6 +26,7 @@ export { FreeMemoryWidget } from './FreeMemory'; export { SessionNameWidget } from './SessionName'; export { SessionUsageWidget } from './SessionUsage'; export { WeeklyUsageWidget } from './WeeklyUsage'; -export { ResetTimerWidget } from './ResetTimer'; +export { BlockResetTimerWidget } from './BlockResetTimer'; +export { WeeklyResetTimerWidget } from './WeeklyResetTimer'; export { ContextBarWidget } from './ContextBar'; export { LinkWidget } from './Link'; \ No newline at end of file