From 7afaeff59aa8b8e824a1e2d626517db0e42e47ea Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sun, 9 Nov 2025 12:38:48 -0500 Subject: [PATCH 1/2] refactor: percentage branded type with controls Signed-off-by: Adam Setch --- src/renderer/__mocks__/state-mocks.ts | 5 +- .../settings/AppearanceSettings.tsx | 24 ++++--- .../settings/SystemSettings.test.tsx | 3 +- .../components/settings/SystemSettings.tsx | 22 ++++--- src/renderer/context/defaults.ts | 5 +- src/renderer/hooks/useNotifications.ts | 2 +- src/renderer/types.ts | 7 ++- .../utils/notifications/sound.test.ts | 28 +++++++++ src/renderer/utils/notifications/sound.ts | 53 +++++++++++++++- src/renderer/utils/zoom.test.ts | 31 +++++++-- src/renderer/utils/zoom.ts | 63 ++++++++++++++++--- 11 files changed, 197 insertions(+), 46 deletions(-) create mode 100644 src/renderer/utils/notifications/sound.test.ts diff --git a/src/renderer/__mocks__/state-mocks.ts b/src/renderer/__mocks__/state-mocks.ts index f8df09750..5dcf04044 100644 --- a/src/renderer/__mocks__/state-mocks.ts +++ b/src/renderer/__mocks__/state-mocks.ts @@ -12,6 +12,7 @@ import { type Link, type NotificationSettingsState, OpenPreference, + type Percentage, type SettingsState, type SystemSettingsState, Theme, @@ -81,7 +82,7 @@ export const mockToken = 'token-123-456' as Token; const mockAppearanceSettings: AppearanceSettingsState = { theme: Theme.SYSTEM, increaseContrast: false, - zoomPercentage: 100, + zoomPercentage: 100 as Percentage, showAccountHeader: false, wrapNotificationTitle: false, }; @@ -111,7 +112,7 @@ const mockSystemSettings: SystemSettingsState = { keyboardShortcut: true, showNotifications: true, playSound: true, - notificationVolume: 20, + notificationVolume: 20 as Percentage, openAtStartup: false, }; diff --git a/src/renderer/components/settings/AppearanceSettings.tsx b/src/renderer/components/settings/AppearanceSettings.tsx index 899bb1c95..6cb966974 100644 --- a/src/renderer/components/settings/AppearanceSettings.tsx +++ b/src/renderer/components/settings/AppearanceSettings.tsx @@ -18,7 +18,13 @@ import { import { AppContext } from '../../context/App'; import { Theme } from '../../types'; import { hasMultipleAccounts } from '../../utils/auth/utils'; -import { zoomLevelToPercentage, zoomPercentageToLevel } from '../../utils/zoom'; +import { + canDecreaseZoom, + canIncreaseZoom, + decreaseZoom, + increaseZoom, + zoomLevelToPercentage, +} from '../../utils/zoom'; import { Checkbox } from '../fields/Checkbox'; import { FieldLabel } from '../fields/FieldLabel'; import { Title } from '../primitives/Title'; @@ -117,13 +123,9 @@ export const AppearanceSettings: FC = () => { - zoomPercentage > 0 && - window.gitify.zoom.setLevel( - zoomPercentageToLevel(zoomPercentage - 10), - ) - } + onClick={() => decreaseZoom(zoomPercentage)} size="small" unsafeDisableTooltip={true} /> @@ -135,13 +137,9 @@ export const AppearanceSettings: FC = () => { - zoomPercentage < 120 && - window.gitify.zoom.setLevel( - zoomPercentageToLevel(zoomPercentage + 10), - ) - } + onClick={() => increaseZoom(zoomPercentage)} size="small" unsafeDisableTooltip={true} /> diff --git a/src/renderer/components/settings/SystemSettings.test.tsx b/src/renderer/components/settings/SystemSettings.test.tsx index eeac5978a..23f4dce74 100644 --- a/src/renderer/components/settings/SystemSettings.test.tsx +++ b/src/renderer/components/settings/SystemSettings.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import { mockAuth, mockSettings } from '../../__mocks__/state-mocks'; import { AppContext } from '../../context/App'; +import type { Percentage } from '../../types'; import { SystemSettings } from './SystemSettings'; describe('renderer/components/settings/SystemSettings.tsx', () => { @@ -155,7 +156,7 @@ describe('renderer/components/settings/SystemSettings.tsx', () => { auth: mockAuth, settings: { ...mockSettings, - notificationVolume: 30, + notificationVolume: 30 as Percentage, }, updateSetting, }} diff --git a/src/renderer/components/settings/SystemSettings.tsx b/src/renderer/components/settings/SystemSettings.tsx index 52f052300..2a85bb994 100644 --- a/src/renderer/components/settings/SystemSettings.tsx +++ b/src/renderer/components/settings/SystemSettings.tsx @@ -8,6 +8,12 @@ import { APPLICATION } from '../../../shared/constants'; import { AppContext } from '../../context/App'; import { defaultSettings } from '../../context/defaults'; import { OpenPreference } from '../../types'; +import { + canDecreaseVolume, + canIncreaseVolume, + decreaseVolume, + increaseVolume, +} from '../../utils/notifications/sound'; import { Checkbox } from '../fields/Checkbox'; import { RadioGroup } from '../fields/RadioGroup'; import { VolumeDownIcon } from '../icons/VolumeDownIcon'; @@ -104,13 +110,13 @@ export const SystemSettings: FC = () => { { - const newVolume = Math.max( - settings.notificationVolume - 10, - 10, + updateSetting( + 'notificationVolume', + decreaseVolume(settings.notificationVolume), ); - updateSetting('notificationVolume', newVolume); }} size="small" unsafeDisableTooltip={true} @@ -123,13 +129,13 @@ export const SystemSettings: FC = () => { { - const newVolume = Math.min( - settings.notificationVolume + 10, - 100, + updateSetting( + 'notificationVolume', + increaseVolume(settings.notificationVolume), ); - updateSetting('notificationVolume', newVolume); }} size="small" unsafeDisableTooltip={true} diff --git a/src/renderer/context/defaults.ts b/src/renderer/context/defaults.ts index 38ead715a..5b831ac78 100644 --- a/src/renderer/context/defaults.ts +++ b/src/renderer/context/defaults.ts @@ -7,6 +7,7 @@ import { GroupBy, type NotificationSettingsState, OpenPreference, + type Percentage, type SettingsState, type SystemSettingsState, Theme, @@ -20,7 +21,7 @@ export const defaultAuth: AuthState = { const defaultAppearanceSettings: AppearanceSettingsState = { theme: Theme.SYSTEM, increaseContrast: false, - zoomPercentage: 100, + zoomPercentage: 100 as Percentage, showAccountHeader: false, wrapNotificationTitle: false, }; @@ -50,7 +51,7 @@ const defaultSystemSettings: SystemSettingsState = { keyboardShortcut: true, showNotifications: true, playSound: true, - notificationVolume: 20, + notificationVolume: 20 as Percentage, openAtStartup: false, }; diff --git a/src/renderer/hooks/useNotifications.ts b/src/renderer/hooks/useNotifications.ts index bae739b9b..58fda90c3 100644 --- a/src/renderer/hooks/useNotifications.ts +++ b/src/renderer/hooks/useNotifications.ts @@ -101,7 +101,7 @@ export const useNotifications = (): NotificationsState => { if (diffNotifications.length > 0) { if (state.settings.playSound) { - raiseSoundNotification(state.settings.notificationVolume / 100); + raiseSoundNotification(state.settings.notificationVolume); } if (state.settings.showNotifications) { diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 33964953b..e3d90b3c5 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -36,6 +36,8 @@ export type SearchToken = Branded; export type Status = 'loading' | 'success' | 'error'; +export type Percentage = Branded; + export interface Account { method: AuthMethod; platform: PlatformType; @@ -53,6 +55,7 @@ export type SettingsValue = | FilterValue[] | GroupBy | OpenPreference + | Percentage | Theme; export type FilterValue = @@ -71,7 +74,7 @@ export type SettingsState = AppearanceSettingsState & export interface AppearanceSettingsState { theme: Theme; increaseContrast: boolean; - zoomPercentage: number; + zoomPercentage: Percentage; showAccountHeader: boolean; wrapNotificationTitle: boolean; } @@ -101,7 +104,7 @@ export interface SystemSettingsState { keyboardShortcut: boolean; showNotifications: boolean; playSound: boolean; - notificationVolume: number; + notificationVolume: Percentage; openAtStartup: boolean; } diff --git a/src/renderer/utils/notifications/sound.test.ts b/src/renderer/utils/notifications/sound.test.ts new file mode 100644 index 000000000..3532c476b --- /dev/null +++ b/src/renderer/utils/notifications/sound.test.ts @@ -0,0 +1,28 @@ +import type { Percentage } from '../../types'; +import { + canDecreaseVolume, + canIncreaseVolume, + volumePercentageToLevel, +} from './sound'; + +describe('renderer/utils/notifications/sound.ts', () => { + it('should convert percentage to sound level', () => { + expect(volumePercentageToLevel(100 as Percentage)).toBe(1); + expect(volumePercentageToLevel(50 as Percentage)).toBe(0.5); + expect(volumePercentageToLevel(0 as Percentage)).toBe(0); + }); + + it('can decrease volume percentage', () => { + expect(canDecreaseVolume(-10 as Percentage)).toBe(false); + expect(canDecreaseVolume(0 as Percentage)).toBe(false); + expect(canDecreaseVolume(10 as Percentage)).toBe(true); + expect(canDecreaseVolume(100 as Percentage)).toBe(true); + }); + + it('can increase volume percentage', () => { + expect(canIncreaseVolume(10 as Percentage)).toBe(true); + expect(canIncreaseVolume(90 as Percentage)).toBe(true); + expect(canIncreaseVolume(100 as Percentage)).toBe(false); + expect(canIncreaseVolume(110 as Percentage)).toBe(false); + }); +}); diff --git a/src/renderer/utils/notifications/sound.ts b/src/renderer/utils/notifications/sound.ts index be7998572..9e9ad71ac 100644 --- a/src/renderer/utils/notifications/sound.ts +++ b/src/renderer/utils/notifications/sound.ts @@ -1,7 +1,56 @@ -export async function raiseSoundNotification(volume: number) { +import type { Percentage } from '../../types'; + +const MINIMUM_VOLUME_PERCENTAGE = 0 as Percentage; +const MAXIMUM_VOLUME_PERCENTAGE = 100 as Percentage; +const VOLUME_STEP = 10 as Percentage; + +export async function raiseSoundNotification(volume: Percentage) { const path = await globalThis.gitify.notificationSoundPath(); const audio = new Audio(path); - audio.volume = volume; + audio.volume = volumePercentageToLevel(volume); audio.play(); } + +/** + * Convert volume percentage (0-100) to level (0.0-1.0) + */ +export function volumePercentageToLevel(percentage: Percentage): number { + return percentage / 100; +} + +/** + * Decrease volume by step amount + */ +export function decreaseVolume(volume: Percentage) { + if (canDecreaseVolume(volume)) { + return volume - VOLUME_STEP; + } + + return volume; +} + +/** + * Increase volume by step amount + */ +export function increaseVolume(volume: Percentage) { + if (canIncreaseVolume(volume)) { + return volume + VOLUME_STEP; + } + + return volume; +} + +/** + * Returns true if can increase volume percentage further + */ +export function canIncreaseVolume(volumePercentage: Percentage) { + return volumePercentage + VOLUME_STEP <= MAXIMUM_VOLUME_PERCENTAGE; +} + +/** + * Returns true if can decrease volume percentage further + */ +export function canDecreaseVolume(volumePercentage: Percentage) { + return volumePercentage - VOLUME_STEP >= MINIMUM_VOLUME_PERCENTAGE; +} diff --git a/src/renderer/utils/zoom.test.ts b/src/renderer/utils/zoom.test.ts index f17e23d0c..294831966 100644 --- a/src/renderer/utils/zoom.test.ts +++ b/src/renderer/utils/zoom.test.ts @@ -1,13 +1,19 @@ -import { zoomLevelToPercentage, zoomPercentageToLevel } from './zoom'; +import type { Percentage } from '../types'; +import { + canDecreaseZoom, + canIncreaseZoom, + zoomLevelToPercentage, + zoomPercentageToLevel, +} from './zoom'; describe('renderer/utils/zoom.ts', () => { it('should convert percentage to zoom level', () => { - expect(zoomPercentageToLevel(100)).toBe(0); - expect(zoomPercentageToLevel(50)).toBe(-1); - expect(zoomPercentageToLevel(0)).toBe(-2); - expect(zoomPercentageToLevel(150)).toBe(1); + expect(zoomPercentageToLevel(100 as Percentage)).toBe(0); + expect(zoomPercentageToLevel(50 as Percentage)).toBe(-1); + expect(zoomPercentageToLevel(0 as Percentage)).toBe(-2); + expect(zoomPercentageToLevel(150 as Percentage)).toBe(1); - expect(zoomPercentageToLevel(undefined)).toBe(0); + expect(zoomPercentageToLevel(undefined as unknown as Percentage)).toBe(0); }); it('should convert zoom level to percentage', () => { @@ -18,4 +24,17 @@ describe('renderer/utils/zoom.ts', () => { expect(zoomLevelToPercentage(undefined)).toBe(100); }); + + it('can decrease zoom percentage', () => { + expect(canDecreaseZoom(-10 as Percentage)).toBe(false); + expect(canDecreaseZoom(0 as Percentage)).toBe(false); + expect(canDecreaseZoom(10 as Percentage)).toBe(true); + }); + + it('can increase zoom percentage', () => { + expect(canIncreaseZoom(10 as Percentage)).toBe(true); + expect(canIncreaseZoom(110 as Percentage)).toBe(true); + expect(canIncreaseZoom(120 as Percentage)).toBe(false); + expect(canIncreaseZoom(150 as Percentage)).toBe(false); + }); }); diff --git a/src/renderer/utils/zoom.ts b/src/renderer/utils/zoom.ts index 244bd3ddd..5f3c89b33 100644 --- a/src/renderer/utils/zoom.ts +++ b/src/renderer/utils/zoom.ts @@ -1,28 +1,73 @@ -const RECOMMENDED = 100; +import { defaultSettings } from '../context/defaults'; +import type { Percentage } from '../types'; + +const MINIMUM_ZOOM_PERCENTAGE = 0 as Percentage; +const MAXIMUM_ZOOM_PERCENTAGE = 120 as Percentage; +const RECOMMENDED_ZOOM_PERCENTAGE = defaultSettings.zoomPercentage; const MULTIPLIER = 2; +const ZOOM_STEP = 10 as Percentage; /** - * Zoom percentage to level. 100% is the recommended zoom level (0). If somehow the percentage is not set, it will return 0, the default zoom level. + * Zoom percentage to level. 100% is the recommended zoom level (0). + * If somehow the percentage is not set, it will return 0, the default zoom level. * @param percentage 0-150 * @returns zoomLevel -2 to 0.5 */ -export function zoomPercentageToLevel(percentage: number): number { +export function zoomPercentageToLevel(percentage: Percentage): number { if (percentage === undefined) { - return 0; + return zoomPercentageToLevel(RECOMMENDED_ZOOM_PERCENTAGE); } - return ((percentage - RECOMMENDED) * MULTIPLIER) / 100; + return ((percentage - RECOMMENDED_ZOOM_PERCENTAGE) * MULTIPLIER) / 100; } /** - * Zoom level to percentage. 0 is the recommended zoom level (100%). If somehow the zoom level is not set, it will return 100, the default zoom percentage. + * Zoom level to percentage. 0 is the recommended zoom level (100%). + * If somehow the zoom level is not set, it will return 100, the default zoom percentage. * @param zoom -2 to 0.5 * @returns percentage 0-150 */ -export function zoomLevelToPercentage(zoom: number): number { +export function zoomLevelToPercentage(zoom: number): Percentage { if (zoom === undefined) { - return 100; + return RECOMMENDED_ZOOM_PERCENTAGE; + } + + return ((zoom / MULTIPLIER) * 100 + + RECOMMENDED_ZOOM_PERCENTAGE) as Percentage; +} + +/** + * Decrease zoom by step amount + */ +export function decreaseZoom(zoomPercentage: Percentage) { + if (canDecreaseZoom(zoomPercentage)) { + window.gitify.zoom.setLevel( + zoomPercentageToLevel((zoomPercentage - ZOOM_STEP) as Percentage), + ); } +} - return (zoom / MULTIPLIER) * 100 + RECOMMENDED; +/** + * Increase zoom by step amount + */ +export function increaseZoom(zoomPercentage: Percentage) { + if (canIncreaseZoom(zoomPercentage)) { + window.gitify.zoom.setLevel( + zoomPercentageToLevel((zoomPercentage + ZOOM_STEP) as Percentage), + ); + } +} + +/** + * Returns true if can increase zoom percentage further + */ +export function canIncreaseZoom(zoomPercentage: Percentage) { + return zoomPercentage + ZOOM_STEP <= MAXIMUM_ZOOM_PERCENTAGE; +} + +/** + * Returns true if can decrease zoom percentage further + */ +export function canDecreaseZoom(zoomPercentage: Percentage) { + return zoomPercentage - ZOOM_STEP >= MINIMUM_ZOOM_PERCENTAGE; } From f035c529db191535acfec7e61b26a5a09bb5c77c Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sun, 9 Nov 2025 12:52:21 -0500 Subject: [PATCH 2/2] refactor: percentage branded type with controls Signed-off-by: Adam Setch --- .../utils/notifications/sound.test.ts | 16 ++++++++++ src/renderer/utils/notifications/sound.ts | 32 +++++++++---------- src/renderer/utils/zoom.ts | 28 ++++++++-------- 3 files changed, 46 insertions(+), 30 deletions(-) diff --git a/src/renderer/utils/notifications/sound.test.ts b/src/renderer/utils/notifications/sound.test.ts index 3532c476b..357c30b9c 100644 --- a/src/renderer/utils/notifications/sound.test.ts +++ b/src/renderer/utils/notifications/sound.test.ts @@ -2,6 +2,8 @@ import type { Percentage } from '../../types'; import { canDecreaseVolume, canIncreaseVolume, + decreaseVolume, + increaseVolume, volumePercentageToLevel, } from './sound'; @@ -19,10 +21,24 @@ describe('renderer/utils/notifications/sound.ts', () => { expect(canDecreaseVolume(100 as Percentage)).toBe(true); }); + it('should decrease volume by step amount', () => { + expect(decreaseVolume(100 as Percentage)).toBe(90); + expect(decreaseVolume(50 as Percentage)).toBe(40); + expect(decreaseVolume(0 as Percentage)).toBe(0); + expect(decreaseVolume(-10 as Percentage)).toBe(0); + }); + it('can increase volume percentage', () => { expect(canIncreaseVolume(10 as Percentage)).toBe(true); expect(canIncreaseVolume(90 as Percentage)).toBe(true); expect(canIncreaseVolume(100 as Percentage)).toBe(false); expect(canIncreaseVolume(110 as Percentage)).toBe(false); }); + + it('should increase volume by step amount', () => { + expect(increaseVolume(0 as Percentage)).toBe(10); + expect(increaseVolume(50 as Percentage)).toBe(60); + expect(increaseVolume(100 as Percentage)).toBe(100); + expect(increaseVolume(110 as Percentage)).toBe(100); + }); }); diff --git a/src/renderer/utils/notifications/sound.ts b/src/renderer/utils/notifications/sound.ts index 9e9ad71ac..548e1e2cc 100644 --- a/src/renderer/utils/notifications/sound.ts +++ b/src/renderer/utils/notifications/sound.ts @@ -19,6 +19,20 @@ export function volumePercentageToLevel(percentage: Percentage): number { return percentage / 100; } +/** + * Returns true if can decrease volume percentage further + */ +export function canDecreaseVolume(volumePercentage: Percentage) { + return volumePercentage - VOLUME_STEP >= MINIMUM_VOLUME_PERCENTAGE; +} + +/** + * Returns true if can increase volume percentage further + */ +export function canIncreaseVolume(volumePercentage: Percentage) { + return volumePercentage + VOLUME_STEP <= MAXIMUM_VOLUME_PERCENTAGE; +} + /** * Decrease volume by step amount */ @@ -27,7 +41,7 @@ export function decreaseVolume(volume: Percentage) { return volume - VOLUME_STEP; } - return volume; + return MINIMUM_VOLUME_PERCENTAGE; } /** @@ -38,19 +52,5 @@ export function increaseVolume(volume: Percentage) { return volume + VOLUME_STEP; } - return volume; -} - -/** - * Returns true if can increase volume percentage further - */ -export function canIncreaseVolume(volumePercentage: Percentage) { - return volumePercentage + VOLUME_STEP <= MAXIMUM_VOLUME_PERCENTAGE; -} - -/** - * Returns true if can decrease volume percentage further - */ -export function canDecreaseVolume(volumePercentage: Percentage) { - return volumePercentage - VOLUME_STEP >= MINIMUM_VOLUME_PERCENTAGE; + return MAXIMUM_VOLUME_PERCENTAGE; } diff --git a/src/renderer/utils/zoom.ts b/src/renderer/utils/zoom.ts index 5f3c89b33..eb72d4289 100644 --- a/src/renderer/utils/zoom.ts +++ b/src/renderer/utils/zoom.ts @@ -36,6 +36,20 @@ export function zoomLevelToPercentage(zoom: number): Percentage { RECOMMENDED_ZOOM_PERCENTAGE) as Percentage; } +/** + * Returns true if can decrease zoom percentage further + */ +export function canDecreaseZoom(zoomPercentage: Percentage) { + return zoomPercentage - ZOOM_STEP >= MINIMUM_ZOOM_PERCENTAGE; +} + +/** + * Returns true if can increase zoom percentage further + */ +export function canIncreaseZoom(zoomPercentage: Percentage) { + return zoomPercentage + ZOOM_STEP <= MAXIMUM_ZOOM_PERCENTAGE; +} + /** * Decrease zoom by step amount */ @@ -57,17 +71,3 @@ export function increaseZoom(zoomPercentage: Percentage) { ); } } - -/** - * Returns true if can increase zoom percentage further - */ -export function canIncreaseZoom(zoomPercentage: Percentage) { - return zoomPercentage + ZOOM_STEP <= MAXIMUM_ZOOM_PERCENTAGE; -} - -/** - * Returns true if can decrease zoom percentage further - */ -export function canDecreaseZoom(zoomPercentage: Percentage) { - return zoomPercentage - ZOOM_STEP >= MINIMUM_ZOOM_PERCENTAGE; -}