feat(usage): split reset timers into block and weekly widgets

Closes #194
This commit is contained in:
Matthew Breedlove committed 2026-03-04 00:14:49 -05:00
1 parent 7e2ad30f77
commit 25b69228dd
11 files changed
+386 -36

No files matched your search

+7 -3
View File
@@ -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
+1 -1
View File
@@ -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",
+32 -1
View File
@@ -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');
+6
View File
@@ -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', () => {
+37 -13
View File
@@ -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<typeof UsageErrorSchema>;
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));
+2 -1
View File
@@ -36,7 +36,8 @@ const widgetRegistry = new Map<WidgetItemType, Widget>([
['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()]
]);
@@ -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 {
+107
View File
@@ -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; }
}
@@ -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);
@@ -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)');
});
});
+2 -1
View File
@@ -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';