fix(transactions): persist the source filter per company and stop the reset race (#1726)

The source filter was persisted under a browser-wide v1 key (#1105), but
the stale-filter guard added in #1124 compared the restored value against
sourceItems before cash accounts, skattekonto rows, and transactions had
loaded, so every mount reset the in-memory filter back to 'Alla källor'
while storage kept the old choice: restore-then-reset on every visit.

- New pure helper components/transactions/source-filter-storage.ts:
  per-company v2 key, isSourceFilter moved out of the page, read/write
  helpers (read removes the retired v1 key once), and
  resolveEffectiveSourceFilter.
- page.tsx keeps sourceFilter as the WANTED filter, restored per company
  (with a state-only ?source= URL override that is never written to
  storage); the guard effect is replaced by a derived
  effectiveSourceFilter memo used by every consumer, so a source that is
  still loading or went stale shows 'all' without destroying the choice.
- ?highlight= deep links widen to 'all' in memory when the wanted filter
  would hide the highlighted row.
- Unit tests for the helper; no i18n changes, no migrations.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-20 10:08:40 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 3de5dee553
commit b77af371c4
4 changed files with 297 additions and 100 deletions
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import {
SOURCE_FILTER_STORAGE_PREFIX,
isSourceFilter,
readStoredSourceFilter,
writeStoredSourceFilter,
resolveEffectiveSourceFilter,
} from '@/components/transactions/source-filter-storage'
const LEGACY_KEY = 'Accounted:transaction-source-filter:v1'
function stubLocalStorage(initial: Record<string, string> = {}) {
const store = new Map<string, string>(Object.entries(initial))
const localStorage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value)
},
removeItem: (key: string) => {
store.delete(key)
},
}
vi.stubGlobal('window', { localStorage })
return store
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('isSourceFilter', () => {
it('accepts every member of the SourceFilter union', () => {
expect(isSourceFilter('all')).toBe(true)
expect(isSourceFilter('bank')).toBe(true)
expect(isSourceFilter('bank:other')).toBe(true)
expect(isSourceFilter('skatteverket')).toBe(true)
expect(isSourceFilter('acct:2f8d1c3a')).toBe(true)
})
it('rejects null and unknown values', () => {
expect(isSourceFilter(null)).toBe(false)
expect(isSourceFilter('')).toBe(false)
expect(isSourceFilter('everything')).toBe(false)
expect(isSourceFilter('account:123')).toBe(false)
expect(isSourceFilter('ALL')).toBe(false)
})
})
describe('resolveEffectiveSourceFilter', () => {
it('keeps the wanted filter when its id is among the items', () => {
expect(resolveEffectiveSourceFilter('acct:a1', ['all', 'acct:a1', 'skatteverket'])).toBe(
'acct:a1',
)
expect(resolveEffectiveSourceFilter('skatteverket', ['all', 'skatteverket'])).toBe(
'skatteverket',
)
})
it('falls back to all when the wanted id is missing (items still loading or source stale)', () => {
expect(resolveEffectiveSourceFilter('acct:a1', ['all'])).toBe('all')
expect(resolveEffectiveSourceFilter('skatteverket', ['all', 'acct:a1'])).toBe('all')
expect(resolveEffectiveSourceFilter('bank:other', ['all', 'acct:a1'])).toBe('all')
})
it('passes all through regardless of items', () => {
expect(resolveEffectiveSourceFilter('all', [])).toBe('all')
expect(resolveEffectiveSourceFilter('all', ['all', 'acct:a1'])).toBe('all')
})
})
describe('readStoredSourceFilter / writeStoredSourceFilter', () => {
it('round-trips a filter under the per-company key', () => {
stubLocalStorage()
writeStoredSourceFilter('company-a', 'acct:a1')
expect(readStoredSourceFilter('company-a')).toBe('acct:a1')
})
it('keeps companies separate: one company never reads another company value', () => {
stubLocalStorage()
writeStoredSourceFilter('company-a', 'acct:a1')
writeStoredSourceFilter('company-b', 'skatteverket')
expect(readStoredSourceFilter('company-a')).toBe('acct:a1')
expect(readStoredSourceFilter('company-b')).toBe('skatteverket')
expect(readStoredSourceFilter('company-c')).toBe('all')
})
it('returns all when the stored value is invalid', () => {
stubLocalStorage({ [SOURCE_FILTER_STORAGE_PREFIX + 'company-a']: 'garbage' })
expect(readStoredSourceFilter('company-a')).toBe('all')
})
it('returns all when localStorage throws', () => {
vi.stubGlobal('window', {
localStorage: {
getItem: () => {
throw new Error('denied')
},
setItem: () => {
throw new Error('denied')
},
removeItem: () => {
throw new Error('denied')
},
},
})
expect(readStoredSourceFilter('company-a')).toBe('all')
// Write must swallow the failure too.
expect(() => writeStoredSourceFilter('company-a', 'bank')).not.toThrow()
})
it('returns all when window is unavailable (SSR/node)', () => {
// No stub: vitest runs in the node environment, so window is undefined.
expect(readStoredSourceFilter('company-a')).toBe('all')
expect(() => writeStoredSourceFilter('company-a', 'bank')).not.toThrow()
})
it('removes the retired browser-wide v1 key on read and ignores its value', () => {
const store = stubLocalStorage({ [LEGACY_KEY]: 'skatteverket' })
expect(readStoredSourceFilter('company-a')).toBe('all')
expect(store.has(LEGACY_KEY)).toBe(false)
})
})