mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-10-13 18:15:21 +00:00
feat(usage): split reset timers into block and weekly widgets
Closes #194
This commit is contained in:
1 parent
7e2ad30f77
commit
25b69228dd
11 files changed
+386
-36
No files matched your search
@@ -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
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
@@ -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; }
|
||||
}
|
||||
+14
-13
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
Reference in new issue
Block a user