From 355e139720b0a3ec23450f799d90a6a0fb460cbb Mon Sep 17 00:00:00 2001 From: Zillatron27 Date: Mon, 6 Jul 2026 20:56:25 +1000 Subject: [PATCH] feat(settings): selectable primary display currency The liquidity pane's headline currency was always derived from the company's faction, but players often trade mainly in a non-faction currency based on where they operate. New settings.preferredCurrency (default null = faction-derived, unchanged behaviour) overrides it via resolvePrimaryCurrency(); Settings gains a Primary Currency picker (Auto | AIC | CIS | ICA | NCC) using the established button-group idiom. Closes #63. Co-Authored-By: Claude Fable 5 --- components/status/CashBalancePane.tsx | 8 ++-- components/views/SettingsView.tsx | 37 ++++++++++++++++++- .../views/__tests__/SettingsView.test.ts | 17 +++++++++ lib/__tests__/currency.test.ts | 23 +++++++++++- lib/currency.ts | 13 +++++++ stores/settings.ts | 7 ++++ 6 files changed, 100 insertions(+), 5 deletions(-) diff --git a/components/status/CashBalancePane.tsx b/components/status/CashBalancePane.tsx index 6b6b2b3..a42beef 100644 --- a/components/status/CashBalancePane.tsx +++ b/components/status/CashBalancePane.tsx @@ -2,7 +2,8 @@ import { useMemo, useState } from 'react'; import { Panel } from '../shared'; import { useBalancesStore } from '../../stores/entities/balances'; import { useCompanyStore } from '../../stores/company'; -import { primaryCurrencyFor, sortBalances } from '../../lib/currency'; +import { useSettingsStore } from '../../stores/settings'; +import { resolvePrimaryCurrency, sortBalances } from '../../lib/currency'; function formatAmount(amount: number): string { return Math.round(amount).toLocaleString('en-US'); @@ -51,15 +52,16 @@ export function CashBalancePane() { const fetched = useBalancesStore((s) => s.fetched); const entities = useBalancesStore((s) => s.entities); const company = useCompanyStore((s) => s.company); + const preferredCurrency = useSettingsStore((s) => s.preferredCurrency); const [expanded, setExpanded] = useState(false); const balances = useMemo( () => sortBalances( Array.from(entities.values()), - company ? primaryCurrencyFor(company.countryId) : null + resolvePrimaryCurrency(company?.countryId ?? null, preferredCurrency) ), - [entities, company] + [entities, company, preferredCurrency] ); const title = company diff --git a/components/views/SettingsView.tsx b/components/views/SettingsView.tsx index 3c490df..d143ab4 100644 --- a/components/views/SettingsView.tsx +++ b/components/views/SettingsView.tsx @@ -29,6 +29,16 @@ const refreshModeOptions: { id: RefreshMode; label: string }[] = [ { id: 'auto', label: 'Auto' }, ]; +// PrUn's currency set is fixed (ECD excluded — legacy, not spendable). +// null = derive from the company's faction (the unchanged default). +const primaryCurrencyOptions: { id: string | null; label: string }[] = [ + { id: null, label: 'Auto' }, + { id: 'AIC', label: 'AIC' }, + { id: 'CIS', label: 'CIS' }, + { id: 'ICA', label: 'ICA' }, + { id: 'NCC', label: 'NCC' }, +]; + /** 24-bit hex number → CSS hex string, for inline preset-preview swatches. */ function toCssHex(value: number): string { return `#${value.toString(16).padStart(6, '0')}`; @@ -150,7 +160,7 @@ export function validateRepairThresholds( } export function SettingsView() { - const { fio, setFioConfig, setFioLastFetch, materialTheme, setMaterialTheme, uiTheme, setUiTheme, burnThresholds, setBurnThresholds } = useSettingsStore(); + const { fio, setFioConfig, setFioLastFetch, materialTheme, setMaterialTheme, uiTheme, setUiTheme, burnThresholds, setBurnThresholds, preferredCurrency, setPreferredCurrency } = useSettingsStore(); // Burn threshold local state — strings for free-form editing, persist on valid input const [critical, setCritical] = useState(String(burnThresholds.critical)); @@ -532,6 +542,31 @@ export function SettingsView() { + {/* Primary Currency Section */} + +
+
+ {primaryCurrencyOptions.map((option) => ( + + ))} +
+

+ The headline balance on the Status tab. Auto uses your company's + faction currency. +

+
+
+ {/* UI Theme Section */} diff --git a/components/views/__tests__/SettingsView.test.ts b/components/views/__tests__/SettingsView.test.ts index fd6259d..65fc851 100644 --- a/components/views/__tests__/SettingsView.test.ts +++ b/components/views/__tests__/SettingsView.test.ts @@ -47,6 +47,23 @@ describe('SettingsView repair threshold validation', () => { }); }); +describe('preferred currency setting (#63)', () => { + beforeEach(() => { + useSettingsStore.getState().reset(); + }); + + it('defaults to null (faction-derived headline currency)', () => { + expect(useSettingsStore.getState().preferredCurrency).toBeNull(); + }); + + it('setPreferredCurrency round-trips a code and back to Auto', () => { + useSettingsStore.getState().setPreferredCurrency('NCC'); + expect(useSettingsStore.getState().preferredCurrency).toBe('NCC'); + useSettingsStore.getState().setPreferredCurrency(null); + expect(useSettingsStore.getState().preferredCurrency).toBeNull(); + }); +}); + describe('SettingsView burn threshold validation', () => { describe('validateThresholds', () => { it('returns null for valid thresholds', () => { diff --git a/lib/__tests__/currency.test.ts b/lib/__tests__/currency.test.ts index 70c5b17..2b6ccd5 100644 --- a/lib/__tests__/currency.test.ts +++ b/lib/__tests__/currency.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { primaryCurrencyFor, sortBalances } from '../currency'; +import { primaryCurrencyFor, resolvePrimaryCurrency, sortBalances } from '../currency'; import type { PrunApi } from '../../types/prun-api'; function balance(currency: string, amount = 1000): PrunApi.CurrencyAmount { @@ -24,6 +24,27 @@ describe('primaryCurrencyFor', () => { }); }); +describe('resolvePrimaryCurrency', () => { + const AI_GUID = '18419e6fd11b5af8bf0d0a996ad1a622'; + + it('override wins over the faction-derived currency (#63)', () => { + expect(resolvePrimaryCurrency(AI_GUID, 'NCC')).toBe('NCC'); + }); + + it('falls back to the faction currency when no override is set', () => { + expect(resolvePrimaryCurrency(AI_GUID, null)).toBe('AIC'); + }); + + it('override works even without company data', () => { + expect(resolvePrimaryCurrency(null, 'CIS')).toBe('CIS'); + }); + + it('returns null with no override and no/unknown countryId', () => { + expect(resolvePrimaryCurrency(null, null)).toBeNull(); + expect(resolvePrimaryCurrency('unknown-guid', null)).toBeNull(); + }); +}); + describe('sortBalances', () => { it('filters out ECD', () => { const result = sortBalances([balance('ECD'), balance('AIC')], null); diff --git a/lib/currency.ts b/lib/currency.ts index b4aa80b..03d7a44 100644 --- a/lib/currency.ts +++ b/lib/currency.ts @@ -25,6 +25,19 @@ export function primaryCurrencyFor(countryId: string): string | null { return COUNTRY_CURRENCY[countryId] ?? null; } +/** + * The currency to headline in the liquidity pane. A user override wins — + * players often trade in a non-faction currency based on where they operate + * (issue #63) — otherwise fall back to the faction-derived primary. + */ +export function resolvePrimaryCurrency( + countryId: string | null, + override: string | null +): string | null { + if (override) return override; + return countryId ? primaryCurrencyFor(countryId) : null; +} + /** * Display order for the liquidity list: ECD filtered out (legacy currency, * not spendable), the company's primary currency first, remaining diff --git a/stores/settings.ts b/stores/settings.ts index 26cd8ff..ed814a2 100644 --- a/stores/settings.ts +++ b/stores/settings.ts @@ -55,6 +55,10 @@ interface SettingsState { * Opt-in per the action-authorisation rule. No UI yet — the Settings * toggle lands with the ACT views (#25 Phase D). */ autoConfirm: boolean; + /** Headline currency for the liquidity pane. null (default) derives it from + * the company's faction; a currency code overrides — players often trade + * in a non-faction currency based on location (#63). */ + preferredCurrency: string | null; } interface SettingsActions { @@ -69,6 +73,7 @@ interface SettingsActions { setNoBuy: (tickers: string[]) => void; setRefreshMode: (mode: RefreshMode) => void; setAutoConfirm: (enabled: boolean) => void; + setPreferredCurrency: (currency: string | null) => void; reset: () => void; } @@ -96,6 +101,7 @@ const initialState: SettingsState = { noBuy: [], refreshMode: 'manual', autoConfirm: false, + preferredCurrency: null, }; // Check if browser storage API is available @@ -195,6 +201,7 @@ export const useSettingsStore = create()( setNoBuy: (tickers) => set({ noBuy: tickers }), setRefreshMode: (mode) => set({ refreshMode: mode }), setAutoConfirm: (enabled) => set({ autoConfirm: enabled }), + setPreferredCurrency: (currency) => set({ preferredCurrency: currency }), reset: () => set(initialState), }),