diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index b0ce0375..0a04489c 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -16,6 +16,8 @@ export interface RenderUsageData { weeklySonnetResetAt?: string; weeklyOpusUsage?: number; weeklyOpusResetAt?: string; + fableUsage?: number; + fableResetAt?: string; extraUsageEnabled?: boolean; extraUsageLimit?: number; extraUsageUsed?: number; diff --git a/src/utils/__tests__/usage-fetch.test.ts b/src/utils/__tests__/usage-fetch.test.ts index 4fb78e39..3429d644 100644 --- a/src/utils/__tests__/usage-fetch.test.ts +++ b/src/utils/__tests__/usage-fetch.test.ts @@ -264,6 +264,19 @@ describe('fetchUsageData error handling', () => { resets_at: '2030-01-07T00:00:00.000Z' } }); + const placeholderFableResponseBody = JSON.stringify({ + five_hour: { + utilization: 42, + resets_at: '2030-01-01T00:00:00.000Z' + }, + seven_day: { + utilization: 17, + resets_at: '2030-01-07T00:00:00.000Z' + }, + limits: [ + { kind: 'weekly_scoped', percent: 0, resets_at: null, scope: { model: { display_name: 'Claude 3.5 Fable' } } } + ] + }); const updatedSuccessResponseBody = JSON.stringify({ five_hour: { utilization: 55, @@ -699,6 +712,92 @@ describe('fetchUsageData error handling', () => { } }); + it('treats a missing fable window as conclusive when core usage fields are present', () => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('missing-fable-conclusive'); + const requiredFields = ['fableUsage']; + const result = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'success', + nowMs, + pathDir: home.bin, + requiredFields, + responseBody: successResponseBody + }); + + expect(result.first).toEqual({ + sessionUsage: 42, + sessionResetAt: '2030-01-01T00:00:00.000Z', + weeklyUsage: 17, + weeklyResetAt: '2030-01-07T00:00:00.000Z' + }); + expect(result.second).toEqual(result.first); + expect(result.requestCount).toBe(1); + + const cacheMtimeMs = fs.statSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.json')).mtimeMs; + const cachedResult = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'unexpected', + nowMs: cacheMtimeMs + 10000, + pathDir: home.bin, + requiredFields + }); + + expect(cachedResult.first).toEqual(result.first); + expect(cachedResult.second).toEqual(result.first); + expect(cachedResult.requestCount).toBe(0); + } finally { + harness.cleanup(); + } + }); + + it('treats a placeholder fable window as conclusive when core usage fields are present', () => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('placeholder-fable-conclusive'); + const requiredFields = ['fableUsage']; + const result = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'success', + nowMs, + pathDir: home.bin, + requiredFields, + responseBody: placeholderFableResponseBody + }); + + expect(result.first).toEqual({ + sessionUsage: 42, + sessionResetAt: '2030-01-01T00:00:00.000Z', + weeklyUsage: 17, + weeklyResetAt: '2030-01-07T00:00:00.000Z' + }); + expect(result.second).toEqual(result.first); + expect(result.requestCount).toBe(1); + + const cacheMtimeMs = fs.statSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.json')).mtimeMs; + const cachedResult = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'unexpected', + nowMs: cacheMtimeMs + 10000, + pathDir: home.bin, + requiredFields + }); + + expect(cachedResult.first).toEqual(result.first); + expect(cachedResult.second).toEqual(result.first); + expect(cachedResult.requestCount).toBe(0); + } finally { + harness.cleanup(); + } + }); + it('clears the in-flight lock after a successful fetch', () => { const harness = createProbeHarness(); @@ -1484,15 +1583,71 @@ describe('parseUsageApiResponse limits[] fallback (#503)', () => { expect(parseUsageApiResponse(placeholderLimitResponseBody)).toEqual({}); }); - it('ignores unknown limits[] kinds and weekly_scoped entries without error', () => { + it('ignores unknown limits[] kinds but consumes fable weekly_scoped entries', () => { expect(parseUsageApiResponse(unknownKindsAndScopedResponseBody)).toEqual({ sessionUsage: 10, sessionResetAt: '2030-05-01T00:00:00.000Z', weeklyUsage: 20, - weeklyResetAt: '2030-05-07T00:00:00.000Z' + weeklyResetAt: '2030-05-07T00:00:00.000Z', + fableUsage: 71, + fableResetAt: '2030-05-07T00:00:00.000Z' }); }); + it('derives fable usage/reset from a weekly_scoped entry with Fable case variants', () => { + expect(parseUsageApiResponse(JSON.stringify({ + limits: [ + { + kind: 'weekly_scoped', + percent: 22, + resets_at: '2030-05-08T00:00:00.000Z', + scope: { model: { display_name: 'claude 3.5 fAbLe' } } + } + ] + }))).toEqual({ + fableUsage: 22, + fableResetAt: '2030-05-08T00:00:00.000Z' + }); + }); + + it('ignores weekly_scoped entry with NON-fable display_name', () => { + expect(parseUsageApiResponse(JSON.stringify({ + limits: [ + { + kind: 'weekly_scoped', + percent: 99, + resets_at: '2030-05-08T00:00:00.000Z', + scope: { model: { display_name: 'Sonnet' } } + } + ] + }))).toEqual({}); + }); + + it('absent when fable placeholder (percent 0, no resets_at)', () => { + expect(parseUsageApiResponse(JSON.stringify({ + limits: [ + { + kind: 'weekly_scoped', + percent: 0, + resets_at: null, + scope: { model: { display_name: 'Fable' } } + } + ] + }))).toEqual({}); + }); + + it('ignores weekly_scoped when no scope -> no crash', () => { + expect(parseUsageApiResponse(JSON.stringify({ + limits: [ + { + kind: 'weekly_scoped', + percent: 10, + resets_at: '2030-05-08T00:00:00.000Z' + } + ] + }))).toEqual({}); + }); + it('behaves identically to before when limits[] is absent', () => { expect(parseUsageApiResponse(noLimitsResponseBody)).toEqual({ sessionUsage: 42, diff --git a/src/utils/__tests__/usage-prefetch.test.ts b/src/utils/__tests__/usage-prefetch.test.ts index c7072182..3bd527ba 100644 --- a/src/utils/__tests__/usage-prefetch.test.ts +++ b/src/utils/__tests__/usage-prefetch.test.ts @@ -217,6 +217,32 @@ describe('usage prefetch', () => { expect(mockFetchUsageData.mock.calls.length).toBe(1); }); + it('triggers API fetch for fable-weekly-usage even when stdin rate_limits is fully populated', async () => { + mockFetchUsageData.mockResolvedValue({ + sessionUsage: 42, + sessionResetAt: '2026-03-20T12:00:00.000Z', + weeklyUsage: 15, + weeklyResetAt: '2026-03-27T12:00:00.000Z', + fableUsage: 5, + fableResetAt: '2026-03-27T12:00:00.000Z' + }); + + const lines = makeLines( + [{ id: '1', type: 'fable-weekly-usage' }] + ); + + const usageData = await prefetchUsageDataIfNeeded(lines, { + rate_limits: { + five_hour: { used_percentage: 42, resets_at: 1774020000 }, + seven_day: { used_percentage: 15, resets_at: 1774540000 } + } + }); + + expect(usageData?.fableUsage).toBe(5); + expect(mockFetchUsageData.mock.calls.length).toBe(1); + expect(mockFetchUsageData.mock.calls[0]).toEqual([{ requiredFields: ['fableUsage'] }]); + }); + it.each([ { apiUsageData: { weeklySonnetUsage: 8 }, @@ -379,6 +405,23 @@ describe('usage prefetch', () => { expect(mockFetchUsageData.mock.calls[0]).toEqual([{ requiredFields: ['weeklySonnetUsage'] }]); }); + it('uses aggregate weekly reset as the cursor fallback without fetching for fable reset data', async () => { + mockFetchUsageData.mockResolvedValue({ fableUsage: 8 }); + + const lines = makeLines( + [{ id: '1', type: 'fable-weekly-usage', metadata: { cursor: 'true' } }] + ); + + const usageData = await prefetchUsageDataIfNeeded(lines, { rate_limits: { seven_day: { resets_at: 1774540000 } } }); + + expect(usageData).toEqual({ + weeklyResetAt: epochToIso(1774540000), + fableUsage: 8 + }); + expect(mockFetchUsageData.mock.calls.length).toBe(1); + expect(mockFetchUsageData.mock.calls[0]).toEqual([{ requiredFields: ['fableUsage'] }]); + }); + it('fetches extra usage fields while preserving statusline usage data', async () => { mockFetchUsageData.mockResolvedValue({ extraUsageEnabled: true, diff --git a/src/utils/usage-fetch.ts b/src/utils/usage-fetch.ts index 5e45e24e..07d2257a 100644 --- a/src/utils/usage-fetch.ts +++ b/src/utils/usage-fetch.ts @@ -34,6 +34,11 @@ const EXTRA_USAGE_DETAIL_FIELDS = new Set([ 'extraUsageUtilization' ]); +const FABLE_USAGE_FIELDS = new Set([ + 'fableUsage', + 'fableResetAt' +]); + // Maps each window reset field to the utilization field parsed from the same // API bucket. A null bucket (Enterprise accounts have no rate-limit windows, // #343) parses to utilization 0 with no resets_at, so once the utilization is @@ -42,7 +47,8 @@ const WINDOW_RESET_FIELD_SENTINELS: Partial; const UsageApiLimitSchema = z.looseObject({ kind: z.string().nullable().optional(), percent: z.number().nullable().optional(), - resets_at: z.string().nullable().optional() + resets_at: z.string().nullable().optional(), + scope: z.looseObject({ model: z.looseObject({ display_name: z.string().nullable().optional() }).nullable().optional() }).nullable().catch(null).optional() }); type UsageApiLimit = z.infer; @@ -159,6 +168,8 @@ function parseCachedUsageData(rawJson: string): UsageData | null { weeklySonnetResetAt: parsed.weeklySonnetResetAt ?? undefined, weeklyOpusUsage: parsed.weeklyOpusUsage ?? undefined, weeklyOpusResetAt: parsed.weeklyOpusResetAt ?? undefined, + fableUsage: parsed.fableUsage ?? undefined, + fableResetAt: parsed.fableResetAt ?? undefined, extraUsageEnabled: parsed.extraUsageEnabled ?? undefined, extraUsageLimit: parsed.extraUsageLimit ?? undefined, extraUsageUsed: parsed.extraUsageUsed ?? undefined, @@ -201,6 +212,7 @@ export function parseUsageApiResponse(rawJson: string): UsageData | null { // the percentage and resets_at can each come from a different source (#503). const sessionLimit = findUsageApiLimit(parsed.limits, 'session'); const weeklyLimit = findUsageApiLimit(parsed.limits, 'weekly_all'); + const fableLimit = parsed.limits?.find(limit => limit.kind === 'weekly_scoped' && (limit.scope?.model?.display_name ?? '').toLowerCase().includes('fable')); return { sessionUsage: getUsageApiBucketUtilization(parsed.five_hour) ?? getUsageApiLimitPercent(sessionLimit), @@ -211,6 +223,8 @@ export function parseUsageApiResponse(rawJson: string): UsageData | null { weeklySonnetResetAt: parsed.seven_day_sonnet?.resets_at ?? undefined, weeklyOpusUsage: getUsageApiBucketUtilization(parsed.seven_day_opus), weeklyOpusResetAt: parsed.seven_day_opus?.resets_at ?? undefined, + fableUsage: getUsageApiLimitPercent(fableLimit), + fableResetAt: getUsageApiLimitResetAt(fableLimit), extraUsageEnabled: parsed.extra_usage?.is_enabled ?? undefined, extraUsageLimit: parsed.extra_usage?.monthly_limit ?? undefined, extraUsageUsed: parsed.extra_usage?.used_credits ?? undefined, @@ -267,7 +281,14 @@ function hasRequiredUsageField(data: UsageData, field: UsageDataField): boolean // Once the API has reported the extra usage state, missing detail fields are // conclusive: accounts without a configured monthly limit never report // monthly_limit/utilization, so refetching cannot produce them (#413). - return data.extraUsageEnabled !== undefined && EXTRA_USAGE_DETAIL_FIELDS.has(field); + if (data.extraUsageEnabled !== undefined && EXTRA_USAGE_DETAIL_FIELDS.has(field)) { + return true; + } + + // Once the API has reported the core usage state, a missing fable window is + // conclusive: legacy accounts and non-Fable plans never report a fable + // limit, so refetching cannot produce it. + return (data.sessionUsage !== undefined || data.weeklyUsage !== undefined) && FABLE_USAGE_FIELDS.has(field); } function hasRequiredUsageFields(data: UsageData, requiredFields: readonly UsageDataField[] = []): boolean { diff --git a/src/utils/usage-prefetch.ts b/src/utils/usage-prefetch.ts index ce7673b5..51962650 100644 --- a/src/utils/usage-prefetch.ts +++ b/src/utils/usage-prefetch.ts @@ -11,6 +11,7 @@ const USAGE_WIDGET_TYPES = new Set([ 'weekly-usage', 'weekly-sonnet-usage', 'weekly-opus-usage', + 'fable-weekly-usage', 'block-timer', 'reset-timer', 'weekly-reset-timer', @@ -28,6 +29,8 @@ const USAGE_DATA_FIELDS: UsageDataField[] = [ 'weeklySonnetResetAt', 'weeklyOpusUsage', 'weeklyOpusResetAt', + 'fableUsage', + 'fableResetAt', 'extraUsageEnabled', 'extraUsageLimit', 'extraUsageUsed', @@ -48,6 +51,7 @@ const USAGE_WIDGET_REQUIREMENTS: Record = { 'weekly-usage': [{ field: 'weeklyUsage' }], 'weekly-sonnet-usage': [{ field: 'weeklySonnetUsage' }], 'weekly-opus-usage': [{ field: 'weeklyOpusUsage' }], + 'fable-weekly-usage': [{ field: 'fableUsage' }], 'block-timer': [{ field: 'sessionResetAt', suppressFetchError: true }], 'reset-timer': [{ field: 'sessionResetAt', suppressFetchError: true }], 'weekly-reset-timer': [{ field: 'weeklyResetAt', suppressFetchError: true }], @@ -70,7 +74,8 @@ const USAGE_CURSOR_REQUIREMENTS: Record = { 'session-usage': { field: 'sessionResetAt' }, 'weekly-usage': { field: 'weeklyResetAt' }, 'weekly-sonnet-usage': { field: 'weeklySonnetResetAt', alternatives: ['weeklyResetAt'] }, - 'weekly-opus-usage': { field: 'weeklyOpusResetAt', alternatives: ['weeklyResetAt'] } + 'weekly-opus-usage': { field: 'weeklyOpusResetAt', alternatives: ['weeklyResetAt'] }, + 'fable-weekly-usage': { field: 'fableResetAt', alternatives: ['weeklyResetAt'] } }; export function hasUsageDependentWidgets(lines: WidgetItem[][]): boolean { @@ -146,6 +151,8 @@ function pickDefinedUsageFields(data: UsageData | null | undefined): Partial new widgets.ExtraUsageUsedWidget() }, { type: 'weekly-sonnet-usage', create: () => new widgets.WeeklySonnetUsageWidget() }, { type: 'weekly-opus-usage', create: () => new widgets.WeeklyOpusUsageWidget() }, + { type: 'fable-weekly-usage', create: () => new widgets.FableWeeklyUsageWidget() }, { type: 'reset-timer', create: () => new widgets.BlockResetTimerWidget() }, { type: 'weekly-reset-timer', create: () => new widgets.WeeklyResetTimerWidget() }, { type: 'context-bar', create: () => new widgets.ContextBarWidget() }, diff --git a/src/widgets/FableWeeklyUsage.ts b/src/widgets/FableWeeklyUsage.ts new file mode 100644 index 00000000..ffc52622 --- /dev/null +++ b/src/widgets/FableWeeklyUsage.ts @@ -0,0 +1,128 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getUsageErrorMessage, + resolveFableUsageWindow +} from '../utils/usage'; + +import { makeTimerProgressBar } from './shared/progress-bar'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +import { + cycleUsageDisplayMode, + getUsageDisplayMode, + getUsageDisplayModifierText, + getUsagePercentCustomKeybinds, + getUsageProgressBarWidth, + isUsageCursorEnabled, + isUsageInverted, + isUsageProgressMode, + isUsageSliderMode, + makeSliderBar, + toggleUsageCursor, + toggleUsageInverted +} from './shared/usage-display'; + +const LABEL = 'Fable Weekly: '; + +export class FableWeeklyUsageWidget implements Widget { + getDefaultColor(): string { return 'brightBlue'; } + getDescription(): string { return 'Shows Fable-only weekly usage percentage'; } + getDisplayName(): string { return 'Weekly Fable Usage'; } + getCategory(): string { return 'Usage'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getUsageDisplayModifierText(item, { showUsageDirection: true }) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === 'toggle-progress') { + return cycleUsageDisplayMode(item, [], true, true); + } + + if (action === 'toggle-invert') { + return toggleUsageInverted(item); + } + + if (action === 'toggle-cursor') { + return toggleUsageCursor(item); + } + + return null; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const displayMode = getUsageDisplayMode(item); + const inverted = isUsageInverted(item); + const showCursor = isUsageCursorEnabled(item); + + if (context.isPreview) { + const previewPercent = 4; + const renderedPercent = inverted ? 100 - previewPercent : previewPercent; + + if (isUsageProgressMode(displayMode)) { + const width = getUsageProgressBarWidth(displayMode); + const progressBar = makeTimerProgressBar(renderedPercent, width, showCursor ? { cursorPercent: 50 } : undefined); + const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + return formatRawOrLabeledValue(item, LABEL, progressDisplay); + } + + if (isUsageSliderMode(displayMode)) { + const slider = makeSliderBar(renderedPercent, undefined, showCursor ? { cursorPercent: 50 } : undefined); + const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + return formatRawOrLabeledValue(item, LABEL, sliderDisplay); + } + + return formatRawOrLabeledValue(item, LABEL, `${renderedPercent.toFixed(1)}%`); + } + + const data = context.usageData ?? {}; + if (data.fableUsage === undefined) { + if (data.error) + return getUsageErrorMessage(data.error); + return null; + } + + const percent = Math.max(0, Math.min(100, data.fableUsage)); + const renderedPercent = inverted ? 100 - percent : percent; + const getCursorOptions = (): { cursorPercent: number } | undefined => { + if (!showCursor) { + return undefined; + } + + const window = resolveFableUsageWindow(data); + return window ? { cursorPercent: window.elapsedPercent } : undefined; + }; + + if (isUsageProgressMode(displayMode)) { + const width = getUsageProgressBarWidth(displayMode); + + const progressBar = makeTimerProgressBar(renderedPercent, width, getCursorOptions()); + const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + return formatRawOrLabeledValue(item, LABEL, progressDisplay); + } + + if (isUsageSliderMode(displayMode)) { + const slider = makeSliderBar(renderedPercent, undefined, getCursorOptions()); + const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + return formatRawOrLabeledValue(item, LABEL, sliderDisplay); + } + + return formatRawOrLabeledValue(item, LABEL, `${renderedPercent.toFixed(1)}%`); + } + + getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { + return getUsagePercentCustomKeybinds(item); + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} diff --git a/src/widgets/__tests__/FableWeeklyUsage.test.ts b/src/widgets/__tests__/FableWeeklyUsage.test.ts new file mode 100644 index 00000000..dfb229e1 --- /dev/null +++ b/src/widgets/__tests__/FableWeeklyUsage.test.ts @@ -0,0 +1,108 @@ +import { + afterEach, + 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 * as usage from '../../utils/usage'; +import type { UsageWindowMetrics } from '../../utils/usage-types'; +import { FableWeeklyUsageWidget } from '../FableWeeklyUsage'; + +import { runUsagePercentWidgetSuite } from './helpers/usage-widget-suites'; + +let mockGetUsageErrorMessage: { mockReturnValue: (value: string) => void }; +const usageErrorMessageMock = { + mockReturnValue(value: string): void { + mockGetUsageErrorMessage.mockReturnValue(value); + } +}; + +const halfElapsedWindow: UsageWindowMetrics = { + sessionDurationMs: 604800000, + elapsedMs: 302400000, + remainingMs: 302400000, + elapsedPercent: 50, + remainingPercent: 50 +}; + +function render(widget: FableWeeklyUsageWidget, item: WidgetItem, context: RenderContext = {}): string | null { + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('FableWeeklyUsageWidget', () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockGetUsageErrorMessage = vi.spyOn(usage, 'getUsageErrorMessage'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders the time cursor in short bar modes', () => { + const widget = new FableWeeklyUsageWidget(); + const context: RenderContext = { usageData: { fableUsage: 20 } }; + + vi.spyOn(usage, 'resolveFableUsageWindow').mockReturnValue(halfElapsedWindow); + + expect(render(widget, { + id: 'fable-weekly', + type: 'fable-weekly-usage', + metadata: { cursor: 'true', display: 'slider' } + }, context)).toBe('Fable Weekly: ▓▓░░░│░░░░ 20.0%'); + expect(render(widget, { + id: 'fable-weekly', + type: 'fable-weekly-usage', + metadata: { cursor: 'true', display: 'slider-only' } + }, context)).toBe('Fable Weekly: ▓▓░░░│░░░░'); + }); + + it('returns null when the per-model usage is missing from the API response', () => { + const widget = new FableWeeklyUsageWidget(); + expect(render(widget, { id: 'fable-weekly', type: 'fable-weekly-usage' }, { usageData: {} })).toBeNull(); + }); + + runUsagePercentWidgetSuite({ + baseItem: { id: 'fable-weekly', type: 'fable-weekly-usage' }, + createWidget: () => new FableWeeklyUsageWidget(), + errorMessageMock: usageErrorMessageMock, + expectedInvertedTime: 'Fable Weekly: 57.9%', + expectedModifierText: '(long bar, remaining)', + expectedPreviewInvertedTime: 'Fable Weekly: 96.0%', + expectedProgress: 'Fable Weekly: [███████████████████░░░░░░░░░░░░░] 57.9%', + expectedRawInvertedTime: '57.9%', + expectedRawProgress: '[███████░░░░░░░░░] 42.1%', + expectedRawTime: '42.1%', + expectedTime: 'Fable Weekly: 42.1%', + modifierItem: { + id: 'fable-weekly', + type: 'fable-weekly-usage', + metadata: { display: 'progress', invert: 'true' } + }, + progressItem: { + id: 'fable-weekly', + type: 'fable-weekly-usage', + metadata: { display: 'progress', invert: 'true' } + }, + rawProgressItem: { + id: 'fable-weekly', + type: 'fable-weekly-usage', + rawValue: true, + metadata: { display: 'progress-short' } + }, + rawTimeItem: { + id: 'fable-weekly', + type: 'fable-weekly-usage', + rawValue: true + }, + render, + usageField: 'fableUsage', + usageValue: 42.06 + }); +}); diff --git a/src/widgets/__tests__/helpers/usage-widget-suites.ts b/src/widgets/__tests__/helpers/usage-widget-suites.ts index 18071cfc..9c31d05e 100644 --- a/src/widgets/__tests__/helpers/usage-widget-suites.ts +++ b/src/widgets/__tests__/helpers/usage-widget-suites.ts @@ -36,7 +36,7 @@ interface UsagePercentWidgetSuiteConfig { rawProgressItem: WidgetItem; rawTimeItem: WidgetItem; render: (widget: TWidget, item: WidgetItem, context?: RenderContext) => string | null; - usageField: 'sessionUsage' | 'weeklyUsage' | 'weeklySonnetUsage' | 'weeklyOpusUsage'; + usageField: 'sessionUsage' | 'weeklyUsage' | 'weeklySonnetUsage' | 'weeklyOpusUsage' | 'fableUsage'; usageValue: number; } @@ -62,7 +62,7 @@ const EXPECTED_TIMER_PROGRESS_KEYBINDS: CustomKeybind[] = [ { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' } ]; -function getUsageContext(field: 'sessionUsage' | 'weeklyUsage' | 'weeklySonnetUsage' | 'weeklyOpusUsage', value: number): RenderContext { +function getUsageContext(field: 'sessionUsage' | 'weeklyUsage' | 'weeklySonnetUsage' | 'weeklyOpusUsage' | 'fableUsage', value: number): RenderContext { return { usageData: { [field]: value } }; } diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 959b4e0d..866ae0f7 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -68,6 +68,7 @@ export { ExtraUsageRemainingWidget } from './ExtraUsageRemaining'; export { ExtraUsageUsedWidget } from './ExtraUsageUsed'; export { WeeklySonnetUsageWidget } from './WeeklySonnetUsage'; export { WeeklyOpusUsageWidget } from './WeeklyOpusUsage'; +export { FableWeeklyUsageWidget } from './FableWeeklyUsage'; export { BlockResetTimerWidget } from './BlockResetTimer'; export { WeeklyResetTimerWidget } from './WeeklyResetTimer'; export { ContextBarWidget } from './ContextBar';