Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/utils/__tests__/usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,31 @@ describe('usage window helpers', () => {
expect(result).toMatch(/^2026-04-27 19:00 \S+$/);
});

it('formats reset timestamps with weekday in UTC', () => {
expect(formatUsageResetAt('2026-03-15T08:30:00.000Z', false, 'UTC', false, false, true)).toBe('Sun 08:30 UTC');
expect(formatUsageResetAt('2026-03-15T08:30:00.000Z', true, 'UTC', false, false, true)).toBe('Sun 08:30Z');
});

it('formats reset timestamps with weekday and 12-hour clock in UTC', () => {
expect(formatUsageResetAt('2026-03-15T08:30:00.000Z', false, 'UTC', true, false, true)).toBe('Sun 8:30 AM UTC');
expect(formatUsageResetAt('2026-03-15T20:30:00.000Z', false, 'UTC', true, false, true)).toBe('Sun 8:30 PM UTC');
expect(formatUsageResetAt('2026-03-15T08:30:00.000Z', true, 'UTC', true, false, true)).toBe('Sun 8:30 AMZ');
});

it('formats reset timestamps with weekday in a specific IANA timezone', () => {
const result = formatUsageResetAt('2026-03-15T08:30:00.000Z', false, 'Asia/Tokyo', 'en-US', false, true);
expect(result).toMatch(/^Sun 17:30 /);
const compactResult = formatUsageResetAt('2026-03-15T08:30:00.000Z', true, 'Asia/Tokyo', 'en-US', false, true);
expect(compactResult).toBe('Sun 17:30');
});

it('formats reset timestamps with weekday and 12-hour clock in a timezone', () => {
const result = formatUsageResetAt('2026-03-15T08:30:00.000Z', false, 'Asia/Tokyo', 'en-US', true, true);
expect(result).toMatch(/^Sun 5:30 PM /);
const compactResult = formatUsageResetAt('2026-03-15T08:30:00.000Z', true, 'Asia/Tokyo', 'en-US', true, true);
expect(compactResult).toBe('Sun 5:30 PM');
});

it('formats duration with days in compact style when >= 24h', () => {
expect(formatUsageDuration(25 * 60 * 60 * 1000, true)).toBe('1d1h');
expect(formatUsageDuration(36.5 * 60 * 60 * 1000, true)).toBe('1d12h30m');
Expand Down
75 changes: 60 additions & 15 deletions src/utils/usage-windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,23 +114,38 @@ function pad(value: number): string {
return value.toString().padStart(2, '0');
}

function formatResetAtUtc(date: Date, compact: boolean, hour12: boolean): string {
const UTC_WEEKDAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];

function formatResetAtUtc(date: Date, compact: boolean, hour12: boolean, weekday = false): string {
const year = date.getUTCFullYear();
const month = pad(date.getUTCMonth() + 1);
const day = pad(date.getUTCDate());
const hours = pad(date.getUTCHours());
const minutes = pad(date.getUTCMinutes());
const weekdayName = weekday ? UTC_WEEKDAY_NAMES[date.getUTCDay()] : '';

if (hour12) {
const hour = date.getUTCHours();
const displayHour = (hour % 12) || 12;
const period = hour >= 12 ? 'PM' : 'AM';

if (weekday) {
return compact
? `${weekdayName} ${displayHour}:${minutes} ${period}Z`
: `${weekdayName} ${displayHour}:${minutes} ${period} UTC`;
}

return compact
? `${month}-${day} ${displayHour}:${minutes} ${period}Z`
: `${year}-${month}-${day} ${displayHour}:${minutes} ${period} UTC`;
}

if (weekday) {
return compact
? `${weekdayName} ${hours}:${minutes}Z`
: `${weekdayName} ${hours}:${minutes} UTC`;
}

return compact
? `${month}-${day} ${hours}:${minutes}Z`
: `${year}-${month}-${day} ${hours}:${minutes} UTC`;
Expand All @@ -147,30 +162,59 @@ function formatResetAtInTimezone(
compact: boolean,
timezone: string | undefined,
locale: string,
hour12: boolean
hour12: boolean,
weekday = false
): string | null {
try {
const formatter = new Intl.DateTimeFormat(locale, {
const options: Intl.DateTimeFormatOptions = {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12,
timeZoneName: 'short'
});
};

if (weekday) {
options.weekday = 'short';
} else {
options.year = 'numeric';
options.month = '2-digit';
options.day = '2-digit';
}

const formatter = new Intl.DateTimeFormat(locale, options);
const parts = formatter.formatToParts(date);
const get = (type: string): string => parts.find(p => p.type === type)?.value ?? '';

const year = get('year');
const month = get('month');
const day = get('day');
const hour = get('hour');
const minute = get('minute');
const dayPeriod = get('dayPeriod');
const tzName = get('timeZoneName');

if (weekday) {
const weekdayName = get('weekday');
if (!weekdayName || !hour || !minute) {
return null;
}

if (hour12) {
const displayHour = hour.startsWith('0') ? hour.slice(1) : hour;
const normalizedDayPeriod = dayPeriod ? normalizeDayPeriod(dayPeriod) : '';
const time = `${displayHour}:${minute}${normalizedDayPeriod ? ` ${normalizedDayPeriod}` : ''}`;
return compact
? `${weekdayName} ${time}`
: `${weekdayName} ${time} ${tzName}`;
}

return compact
? `${weekdayName} ${hour}:${minute}`
: `${weekdayName} ${hour}:${minute} ${tzName}`;
}

const year = get('year');
const month = get('month');
const day = get('day');

if (!year || !month || !day || !hour || !minute) {
return null;
}
Expand All @@ -197,7 +241,8 @@ export function formatUsageResetAt(
compact = false,
timezone?: string,
localeOrHour12?: string | boolean,
hour12Arg = false
hour12Arg = false,
weekday = false
): string | null {
if (!resetAt) {
return null;
Expand All @@ -213,24 +258,24 @@ export function formatUsageResetAt(
const hour12 = typeof localeOrHour12 === 'boolean' ? localeOrHour12 : hour12Arg;

if (!timezone || timezone === 'UTC') {
return formatResetAtUtc(date, compact, hour12);
return formatResetAtUtc(date, compact, hour12, weekday);
}

const resolvedTimezone = timezone === 'local' ? undefined : timezone;
const resolvedLocale = locale && locale.length > 0 ? locale : DEFAULT_TZ_LOCALE;
const localized = formatResetAtInTimezone(date, compact, resolvedTimezone, resolvedLocale, hour12);
const localized = formatResetAtInTimezone(date, compact, resolvedTimezone, resolvedLocale, hour12, weekday);
if (localized) {
return localized;
}

if (resolvedLocale !== DEFAULT_TZ_LOCALE) {
const fallback = formatResetAtInTimezone(date, compact, resolvedTimezone, DEFAULT_TZ_LOCALE, hour12);
const fallback = formatResetAtInTimezone(date, compact, resolvedTimezone, DEFAULT_TZ_LOCALE, hour12, weekday);
if (fallback) {
return fallback;
}
}

return formatResetAtUtc(date, compact, hour12);
return formatResetAtUtc(date, compact, hour12, weekday);
}

export function getUsageErrorMessage(error: UsageError): string {
Expand Down
24 changes: 20 additions & 4 deletions src/widgets/WeeklyResetTimer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,13 @@ import {
isUsageInverted,
isUsageProgressMode,
isUsageSliderMode,
isUsageWeekdayEnabled,
makeSliderBar,
toggleUsageCompact,
toggleUsageDateMode,
toggleUsageHourFormat,
toggleUsageInverted
toggleUsageInverted,
toggleUsageWeekday
} from './shared/usage-display';

function makeTimerProgressBar(percent: number, width: number): string {
Expand Down Expand Up @@ -101,6 +103,10 @@ function getWeeklyResetModifierText(item: WidgetItem): string | undefined {
if (isUsage12HourClock(item)) {
modifiers.push('12hr');
}

if (isUsageWeekdayEnabled(item)) {
modifiers.push('weekday');
}
} else if (isWeeklyResetHoursOnly(item)) {
modifiers.push('hours only');
}
Expand Down Expand Up @@ -153,6 +159,10 @@ export class WeeklyResetTimerWidget implements Widget {
return toggleUsageHourFormat(item);
}

if (action === 'toggle-weekday') {
return toggleUsageWeekday(item);
}

if (action === 'toggle-hours') {
return toggleWeeklyResetHoursOnly(item);
}
Expand Down Expand Up @@ -185,14 +195,19 @@ export class WeeklyResetTimerWidget implements Widget {
}

if (dateMode) {
const weekday = isUsageWeekdayEnabled(item);
const resetAt = formatUsageResetAt(
WEEKLY_RESET_PREVIEW_AT,
compact,
getUsageTimezone(item),
getUsageLocale(item),
isUsage12HourClock(item)
isUsage12HourClock(item),
weekday
);
return formatRawOrLabeledValue(item, 'Weekly Reset: ', resetAt ?? (compact ? '03-15 08:30Z' : '2026-03-15 08:30 UTC'));
const fallback = weekday
? (compact ? 'Sun 08:30Z' : 'Sun 08:30 UTC')
: (compact ? '03-15 08:30Z' : '2026-03-15 08:30 UTC');
return formatRawOrLabeledValue(item, 'Weekly Reset: ', resetAt ?? fallback);
}

return formatRawOrLabeledValue(item, 'Weekly Reset: ', formatUsageDuration(WEEKLY_PREVIEW_DURATION_MS, compact, useDays));
Expand Down Expand Up @@ -229,7 +244,7 @@ export class WeeklyResetTimerWidget implements Widget {
if (dateMode) {
const timezone = getUsageTimezone(item);
const locale = getUsageLocale(item);
const resetAt = formatUsageResetAt(usageData.weeklyResetAt, compact, timezone, locale, isUsage12HourClock(item));
const resetAt = formatUsageResetAt(usageData.weeklyResetAt, compact, timezone, locale, isUsage12HourClock(item), isUsageWeekdayEnabled(item));
if (resetAt) {
return formatRawOrLabeledValue(item, 'Weekly Reset: ', resetAt);
}
Expand All @@ -243,6 +258,7 @@ export class WeeklyResetTimerWidget implements Widget {
const keybinds = getUsageTimerCustomKeybinds(item, {
includeDate: true,
includeHourFormat: true,
includeWeekday: true,
includeLocale: true,
includeTimezone: true
});
Expand Down
64 changes: 63 additions & 1 deletion src/widgets/__tests__/WeeklyResetTimer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ describe('WeeklyResetTimerWidget', () => {
{ id: 'weekly-reset', type: 'weekly-reset-timer', metadata: { absolute: 'true', timezone: 'Asia/Tokyo', locale: 'ja-JP', hour12: 'true' } },
{ usageData: { weeklyResetAt: '2026-03-15T08:30:00.000Z' } }
)).toBe('Weekly Reset: 2026-03-15 08:30 UTC');
expect(mockFormatUsageResetAt).toHaveBeenCalledWith('2026-03-15T08:30:00.000Z', false, 'Asia/Tokyo', 'ja-JP', true);
expect(mockFormatUsageResetAt).toHaveBeenCalledWith('2026-03-15T08:30:00.000Z', false, 'Asia/Tokyo', 'ja-JP', true, false);
});

it('shows configured timestamp settings in editor display only in timestamp mode', () => {
Expand Down Expand Up @@ -209,6 +209,67 @@ describe('WeeklyResetTimerWidget', () => {
expect(cleared?.metadata?.hour12).toBe('false');
});

it('toggles weekday metadata', () => {
const widget = new WeeklyResetTimerWidget();
const baseItem: WidgetItem = {
id: 'weekly-reset',
type: 'weekly-reset-timer',
metadata: { absolute: 'true' }
};

const withWeekday = widget.handleEditorAction('toggle-weekday', baseItem);
const cleared = widget.handleEditorAction('toggle-weekday', withWeekday ?? baseItem);

expect(withWeekday?.metadata?.weekday).toBe('true');
expect(cleared?.metadata?.weekday).toBe('false');
});

it('shows weekday modifier text when weekday is enabled in date mode', () => {
const widget = new WeeklyResetTimerWidget();

expect(widget.getEditorDisplay({
id: 'weekly-reset',
type: 'weekly-reset-timer',
metadata: { absolute: 'true', weekday: 'true' }
}).modifierText).toBe('(date, weekday)');
expect(widget.getEditorDisplay({
id: 'weekly-reset',
type: 'weekly-reset-timer',
metadata: { absolute: 'true', hour12: 'true', weekday: 'true' }
}).modifierText).toBe('(date, 12hr, weekday)');
});

it('renders weekday format in preview date mode', () => {
const widget = new WeeklyResetTimerWidget();

mockFormatUsageResetAt.mockReturnValue('Sun 08:30 UTC');

expect(render(widget, {
id: 'weekly-reset',
type: 'weekly-reset-timer',
metadata: { absolute: 'true', weekday: 'true' }
}, { isPreview: true })).toBe('Weekly Reset: Sun 08:30 UTC');
});

it('renders weekday format in live date mode', () => {
const widget = new WeeklyResetTimerWidget();

mockResolveWeeklyUsageWindow.mockReturnValue({
sessionDurationMs: 604800000,
elapsedMs: 171900000,
remainingMs: 432900000,
elapsedPercent: 28.4216269841,
remainingPercent: 71.5783730159
});
mockFormatUsageResetAt.mockReturnValue('Sun 5:30 PM GMT+9');

expect(render(widget,
{ id: 'weekly-reset', type: 'weekly-reset-timer', metadata: { absolute: 'true', weekday: 'true', hour12: 'true', timezone: 'Asia/Tokyo' } },
{ usageData: { weeklyResetAt: '2026-03-15T08:30:00.000Z' } }
)).toBe('Weekly Reset: Sun 5:30 PM GMT+9');
expect(mockFormatUsageResetAt).toHaveBeenCalledWith('2026-03-15T08:30:00.000Z', false, 'Asia/Tokyo', undefined, true, true);
});

it('clears compact and hours-only metadata when cycling into progress mode', () => {
const widget = new WeeklyResetTimerWidget();
const updated = widget.handleEditorAction('toggle-progress', {
Expand Down Expand Up @@ -253,6 +314,7 @@ describe('WeeklyResetTimerWidget', () => {
{ key: 's', label: '(s)hort time', action: 'toggle-compact' },
{ key: 't', label: '(t)imestamp', action: 'toggle-date' },
{ key: 'h', label: '12/24 (h)our', action: 'toggle-hour-format' },
{ key: 'w', label: '(w)eekday', action: 'toggle-weekday' },
{ key: 'z', label: 'time(z)one', action: 'edit-timezone' },
{ key: 'l', label: '(l)ocale', action: 'edit-locale' }
]);
Expand Down
14 changes: 14 additions & 0 deletions src/widgets/shared/usage-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const COMPACT_TOGGLE_KEYBIND: CustomKeybind = { key: 's', label: '(s)hort time',
const CURSOR_TOGGLE_KEYBIND: CustomKeybind = { key: 't', label: '(t)ime cursor', action: 'toggle-cursor' };
const DATE_TOGGLE_KEYBIND: CustomKeybind = { key: 't', label: '(t)imestamp', action: 'toggle-date' };
const HOUR_FORMAT_TOGGLE_KEYBIND: CustomKeybind = { key: 'h', label: '12/24 (h)our', action: 'toggle-hour-format' };
const WEEKDAY_TOGGLE_KEYBIND: CustomKeybind = { key: 'w', label: '(w)eekday', action: 'toggle-weekday' };
const TIMEZONE_KEYBIND: CustomKeybind = { key: 'z', label: 'time(z)one', action: 'edit-timezone' };
const LOCALE_KEYBIND: CustomKeybind = { key: 'l', label: '(l)ocale', action: 'edit-locale' };

Expand Down Expand Up @@ -155,6 +156,14 @@ export function toggleUsageHourFormat(item: WidgetItem): WidgetItem {
return toggleMetadataFlag(item, 'hour12');
}

export function isUsageWeekdayEnabled(item: WidgetItem): boolean {
return isMetadataFlagEnabled(item, 'weekday');
}

export function toggleUsageWeekday(item: WidgetItem): WidgetItem {
return toggleMetadataFlag(item, 'weekday');
}

interface UsageDisplayModifierOptions {
includeCompact?: boolean;
includeDate?: boolean;
Expand Down Expand Up @@ -269,6 +278,7 @@ interface UsageTimerCustomKeybindOptions {
includeHourFormat?: boolean;
includeLocale?: boolean;
includeTimezone?: boolean;
includeWeekday?: boolean;
}

export function getUsageTimerCustomKeybinds(
Expand All @@ -295,6 +305,10 @@ export function getUsageTimerCustomKeybinds(
keybinds.push(HOUR_FORMAT_TOGGLE_KEYBIND);
}

if (options.includeWeekday) {
keybinds.push(WEEKDAY_TOGGLE_KEYBIND);
}

if (options.includeTimezone) {
keybinds.push(TIMEZONE_KEYBIND);
}
Expand Down