diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx
index 511ab126..099326b6 100644
--- a/app/(dashboard)/reports/page.tsx
+++ b/app/(dashboard)/reports/page.tsx
@@ -3,7 +3,6 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
-import { Skeleton } from '@/components/ui/skeleton'
import { PageHeader } from '@/components/ui/page-header'
import { HelpPopover } from '@/components/ui/help-popover'
import { EmptyState } from '@/components/ui/empty-state'
@@ -25,7 +24,10 @@ import { getReport } from '@/lib/reports/catalog'
export default function ReportsPage() {
const router = useRouter()
const [selectedPeriod, setSelectedPeriod] = useState('')
- const [isLoadingInit, setIsLoadingInit] = useState(true)
+ // The catalog itself is static; only the "no fiscal year" empty state has
+ // to wait for the picker to finish restoring its scope (one effect tick
+ // when the periods are seeded, see FyPicker).
+ const [fyReady, setFyReady] = useState(false)
const { company } = useCompany()
const { settings } = useCompanySettings()
const t = useTranslations('reports')
@@ -62,18 +64,12 @@ export default function ReportsPage() {
onChange={(id) => setSelectedPeriod(id || '')}
includeAllOption={false}
hideFuturePeriods
- onReady={() => setIsLoadingInit(false)}
+ onReady={() => setFyReady(true)}
/>
}
/>
- {isLoadingInit ? (
-
- {[1, 2, 3, 4, 5, 6].map((i) => (
-
- ))}
-
- ) : !selectedPeriod ? (
+ {fyReady && !selectedPeriod ? (
([])
+ // Registered, enabled cash accounts for the account chooser: session-cached
+ // and seeded by the dashboard layout (lib/reference-data), so the chooser
+ // renders populated on the first paint. Bank sync invalidates the entry.
+ const { cashAccounts } = useCashAccounts({ enabledOnly: true })
const { toast } = useToast()
const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm()
@@ -882,24 +886,6 @@ export default function TransactionsPage() {
const PAGE_SIZE = 200
- // Account chooser rows: the registered, enabled cash accounts. One fetch
- // per company; balances refresh with the page (bank sync triggers a
- // router.refresh via the sync toast flow).
- useEffect(() => {
- if (!companyId) return
- let cancelled = false
- fetch('/api/cash-accounts?enabled_only=true')
- .then((res) => (res.ok ? res.json() : { data: [] }))
- .then((json: { data?: CashAccount[] }) => {
- if (!cancelled) setCashAccounts(json.data ?? [])
- })
- .catch(() => {
- if (!cancelled) setCashAccounts([])
- })
- return () => {
- cancelled = true
- }
- }, [companyId])
// Same sequence-guard pattern as fetchGenerationRef: only the newest
// skattekonto fetch may write rows. A company switch bumps the sequence so a
diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx
index 501bfd84..545d8348 100644
--- a/components/bookkeeping/JournalEntryList.tsx
+++ b/components/bookkeeping/JournalEntryList.tsx
@@ -46,6 +46,7 @@ import { ArrowDown, ArrowUp, ArrowUpDown, ChevronRight, ChevronLeft, ChevronsLef
import { cn, formatDate, formatCurrency } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { resolveCurrentPeriodId } from '@/lib/bookkeeping/suggest-fiscal-period'
+import { useFiscalPeriods } from '@/lib/reference-data/hooks'
import { Input } from '@/components/ui/input'
import { AccountNumber } from '@/components/ui/account-number'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
@@ -61,7 +62,7 @@ import { getErrorMessage } from '@/lib/errors/get-error-message'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import { listContextKey, writeListContext } from '@/lib/navigation/list-context'
import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/types'
-import type { FiscalPeriod, JournalEntry, JournalEntryLine } from '@/types'
+import type { JournalEntry, JournalEntryLine } from '@/types'
// Shared source of truth (lib/worklist/types.ts) so the per-row chip and
// waiver UI can never drift from the worklist count and the SQL predicate
@@ -444,7 +445,7 @@ export default function JournalEntryList({
setPageSizeHydrated(true)
}, [company?.id])
- // Fetch fiscal periods AND resolve the initial fiscal-year scope in one pass.
+ // Resolve the initial fiscal-year scope from the session-cached period list.
// The list is period-oriented (BFL): verifikationsnummer run as an unbroken
// series *per räkenskapsår*, so the same number (e.g. A42) recurs once per
// year. Showing every year at once makes those look like duplicates and makes
@@ -454,12 +455,22 @@ export default function JournalEntryList({
// Resolving the scope here, not in the dialog's FiscalYearSelector, which
// only mounts when opened, keeps the first fetch correct. periodHydrated
// gates that first fetch so the list loads already scoped to the resolved year.
+ //
+ // The periods come from useFiscalPeriods (seeded by the dashboard layout),
+ // so on a normal visit this resolves in the first effect tick with no
+ // round trip; the saved-scope shortcut below still unblocks the entries
+ // fetch first when the list is not cached yet. Resolution runs once per
+ // company: a background revalidation of the list must not snap the scope
+ // back (the deep-link "all years" visit in particular).
+ const { periods: fiscalPeriods, isLoading: fiscalPeriodsLoading } = useFiscalPeriods()
+ const periodScopeResolvedForRef = useRef(null)
useEffect(() => {
if (!company?.id) {
setPeriodId(null)
setPeriodHydrated(true)
return
}
+ if (periodScopeResolvedForRef.current === company.id) return
// Deep-link arrival with the missing-underlag filter: scope this visit to
// all fiscal years (in memory only) so the list can show the same set the
@@ -467,6 +478,7 @@ export default function JournalEntryList({
// FyPicker afterwards works and persists as usual.
if (deepLinkAllYearsRef.current) {
deepLinkAllYearsRef.current = false
+ periodScopeResolvedForRef.current = company.id
setPeriodId(null)
setPeriodHydrated(true)
return
@@ -477,43 +489,29 @@ export default function JournalEntryList({
? window.localStorage.getItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id)
: null
// Optimistic hydration: a saved scope unblocks the first entries fetch
- // immediately instead of serializing it behind the fiscal-periods
- // round-trip (the common returning-user case). The fetch below still
- // validates a saved period id and re-scopes to the current räkenskapsår
- // if it went stale (e.g. the period was deleted), the entries effect
- // then refires with the corrected scope.
+ // immediately instead of waiting for the period list (the common
+ // returning-user case without a seed). The validation below still
+ // re-scopes to the current räkenskapsår if the saved id went stale (e.g.
+ // the period was deleted); the entries effect then refires with the
+ // corrected scope.
if (stored) {
// FISCAL_YEAR_ALL_VALUE = user explicitly chose "all years", respect it.
setPeriodId(stored === FISCAL_YEAR_ALL_VALUE ? null : stored)
setPeriodHydrated(true)
}
- let cancelled = false
- ;(async () => {
- let fetched: FiscalPeriod[] = []
- try {
- const res = await fetch('/api/bookkeeping/fiscal-periods')
- if (res.ok) {
- const { data } = await res.json()
- fetched = (data || []) as FiscalPeriod[]
- }
- } catch {
- // Non-critical: fall through with an empty list (scope stays "all years").
- }
- if (cancelled) return
+ // Not cached yet and no seed: wait for the list, this effect re-runs.
+ if (fiscalPeriodsLoading) return
+ periodScopeResolvedForRef.current = company.id
- if (stored === FISCAL_YEAR_ALL_VALUE) return
- if (stored && fetched.some((p) => p.id === stored)) return
+ if (stored === FISCAL_YEAR_ALL_VALUE) return
+ if (stored && fiscalPeriods.some((p) => p.id === stored)) return
- // No (valid) saved scope → default to the current räkenskapsår.
- const today = new Date().toISOString().split('T')[0]
- setPeriodId(resolveCurrentPeriodId(fetched, today))
- setPeriodHydrated(true)
- })()
- return () => {
- cancelled = true
- }
- }, [company?.id])
+ // No (valid) saved scope → default to the current räkenskapsår.
+ const today = new Date().toISOString().split('T')[0]
+ setPeriodId(resolveCurrentPeriodId(fiscalPeriods, today))
+ setPeriodHydrated(true)
+ }, [company?.id, fiscalPeriods, fiscalPeriodsLoading])
// Debounce the free-text search before it reaches the API. Require ≥2 chars:
// a single character matches almost every verifikationstext and isn't a useful
diff --git a/components/common/FiscalYearSelector.tsx b/components/common/FiscalYearSelector.tsx
index c7d36194..1cb615d7 100644
--- a/components/common/FiscalYearSelector.tsx
+++ b/components/common/FiscalYearSelector.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useEffect, useState } from 'react'
+import { useEffect, useMemo, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
@@ -13,12 +13,15 @@ import {
} from '@/components/ui/select'
import { Lock } from 'lucide-react'
import { useCompany } from '@/contexts/CompanyContext'
+import { STORAGE_KEY_PREFIX, ALL_YEARS_VALUE } from '@/components/common/fiscal-year-storage'
+import { useFiscalPeriods } from '@/lib/reference-data/hooks'
+import { prepareFiscalPeriods, resolveInitialFiscalScope } from '@/lib/reference-data/fiscal-scope'
import type { FiscalPeriod } from '@/types'
-// Exported so other surfaces (e.g. JournalEntryList's filter dialog) can read
-// and write the same persisted selection without duplicating the magic string.
-export const STORAGE_KEY_PREFIX = 'Accounted:fiscal-year:'
-export const ALL_YEARS_VALUE = '__all__'
+// Re-exported so other surfaces (e.g. JournalEntryList's filter dialog) can
+// read and write the same persisted selection without duplicating the magic
+// string. The values live in fiscal-year-storage.ts (dependency-free).
+export { STORAGE_KEY_PREFIX, ALL_YEARS_VALUE }
interface Props {
/**
@@ -56,13 +59,6 @@ interface Props {
initialCompanyId?: string | null
}
-function preparePeriods(periods: FiscalPeriod[], hideFuturePeriods: boolean): FiscalPeriod[] {
- const today = new Date().toISOString().split('T')[0]
- return periods
- .filter((period) => !hideFuturePeriods || period.period_start <= today)
- .sort((a, b) => b.period_start.localeCompare(a.period_start))
-}
-
/**
* Shared fiscal-year (räkenskapsår) selector.
*
@@ -86,11 +82,16 @@ export function FiscalYearSelector({
}: Props) {
const { company } = useCompany()
const t = useTranslations('fiscal_year')
+ // Session-cached and seeded by the dashboard layout (see FyPicker): the
+ // restore runs in the first effect tick on a normal visit, no round trip.
+ const { periods: cachedPeriods, isLoading } = useFiscalPeriods()
const canUseInitialPeriods = initialCompanyId === company?.id && initialPeriods !== undefined
- const [periods, setPeriods] = useState(() =>
- canUseInitialPeriods ? preparePeriods(initialPeriods, hideFuturePeriods) : [],
+ const periods = useMemo(
+ () => prepareFiscalPeriods(canUseInitialPeriods ? initialPeriods : cachedPeriods, hideFuturePeriods),
+ [canUseInitialPeriods, initialPeriods, cachedPeriods, hideFuturePeriods],
)
- const [loaded, setLoaded] = useState(canUseInitialPeriods)
+ const loaded = canUseInitialPeriods || !isLoading
+ const restoredForRef = useRef(null)
const effectiveLabel = label === null ? null : (label ?? t('label'))
useEffect(() => {
@@ -100,52 +101,21 @@ export function FiscalYearSelector({
onReady?.()
return
}
- let cancelled = false
- ;(async () => {
- let fetched: FiscalPeriod[]
- if (initialCompanyId === company.id && initialPeriods !== undefined) {
- fetched = preparePeriods(initialPeriods, hideFuturePeriods)
- } else {
- const res = await fetch('/api/bookkeeping/fiscal-periods')
- if (!res.ok) {
- if (!cancelled) {
- setLoaded(true)
- onReady?.()
- }
- return
- }
- const { data } = await res.json()
- fetched = preparePeriods(data || [], hideFuturePeriods)
- }
- if (cancelled) return
+ if (!loaded || restoredForRef.current === company.id) return
+ restoredForRef.current = company.id
- setPeriods(fetched)
- setLoaded(true)
-
- // Restore last selection (only if caller hasn't already set a value).
- // localStorage access is guarded because this is a 'use client' component
- // but still runs during SSR on first render for some setups.
- if (value === null && typeof window !== 'undefined') {
- const stored = window.localStorage.getItem(STORAGE_KEY_PREFIX + company.id)
- if (stored === ALL_YEARS_VALUE) {
- if (includeAllOption) onChange(null, null)
- else if (fetched.length > 0) onChange(fetched[0].id, fetched[0])
- } else if (stored && fetched.some((p) => p.id === stored)) {
- onChange(stored, fetched.find((p) => p.id === stored) ?? null)
- } else if (!includeAllOption && fetched.length > 0) {
- onChange(fetched[0].id, fetched[0])
- }
- }
-
- onReady?.()
- })()
- return () => {
- cancelled = true
+ // Restore last selection (only if caller hasn't already set a value).
+ if (value === null && typeof window !== 'undefined') {
+ const stored = window.localStorage.getItem(STORAGE_KEY_PREFIX + company.id)
+ const pick = resolveInitialFiscalScope(periods, stored, { includeAllOption })
+ if (pick) onChange(pick.periodId, pick.period)
}
- // onReady is intentionally excluded from deps: it's a lifecycle callback that
- // should fire once per load, not re-trigger if the parent re-creates it.
+
+ onReady?.()
+ // onReady/onChange are lifecycle callbacks that fire once per load, not
+ // again when the parent re-creates them. `value` is read once at restore.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [company?.id, hideFuturePeriods, includeAllOption, initialCompanyId, initialPeriods])
+ }, [company?.id, loaded, periods])
const handleChange = (next: string) => {
const nextPeriodId = next === ALL_YEARS_VALUE ? null : next
diff --git a/components/common/FyPicker.tsx b/components/common/FyPicker.tsx
index 708bf97f..c6f92268 100644
--- a/components/common/FyPicker.tsx
+++ b/components/common/FyPicker.tsx
@@ -1,13 +1,12 @@
'use client'
-import { useEffect, useState } from 'react'
+import { useEffect, useMemo, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { useCompany } from '@/contexts/CompanyContext'
import { ContextPicker } from '@/components/common/ContextPicker'
-import {
- STORAGE_KEY_PREFIX,
- ALL_YEARS_VALUE,
-} from '@/components/common/FiscalYearSelector'
+import { STORAGE_KEY_PREFIX, ALL_YEARS_VALUE } from '@/components/common/fiscal-year-storage'
+import { useFiscalPeriods } from '@/lib/reference-data/hooks'
+import { prepareFiscalPeriods, resolveInitialFiscalScope } from '@/lib/reference-data/fiscal-scope'
import type { FiscalPeriod } from '@/types'
interface FyPickerProps {
@@ -73,13 +72,6 @@ interface FyPickerProps {
className?: string
}
-function preparePeriods(periods: FiscalPeriod[], hideFuturePeriods: boolean): FiscalPeriod[] {
- const today = new Date().toISOString().split('T')[0]
- return periods
- .filter((p) => !hideFuturePeriods || p.period_start <= today)
- .sort((a, b) => b.period_start.localeCompare(a.period_start))
-}
-
/**
* Fiscal-year context picker (UI-migration plan PR 3): the chip-dropdown
* "Räkenskapsår 2026" with a check on the active choice and closed/locked
@@ -103,76 +95,48 @@ export function FyPicker({
}: FyPickerProps) {
const { company } = useCompany()
const t = useTranslations('fiscal_year')
+ // Session-cached and seeded by the dashboard layout, so on a normal visit
+ // the list is already here on the first render: the restore below runs in
+ // the first effect tick and onReady fires without a network round trip.
+ // initialPeriods remains an explicit override for server-rendered pages.
+ const { periods: cachedPeriods, isLoading } = useFiscalPeriods()
const canUseInitial = initialCompanyId === company?.id && initialPeriods !== undefined
- const [periods, setPeriods] = useState(() =>
- canUseInitial ? preparePeriods(initialPeriods, hideFuturePeriods) : [],
+ const periods = useMemo(
+ () => prepareFiscalPeriods(canUseInitial ? initialPeriods : cachedPeriods, hideFuturePeriods),
+ [canUseInitial, initialPeriods, cachedPeriods, hideFuturePeriods],
)
- const [loaded, setLoaded] = useState(canUseInitial)
+ const loaded = canUseInitial || !isLoading
+ // Restore once per company load, not on every background revalidation of
+ // the cached list (which would re-fire onChange/onReady mid-session).
+ const restoredForRef = useRef(null)
useEffect(() => {
if (!company?.id) {
onReady?.()
return
}
- let cancelled = false
- ;(async () => {
- let fetched: FiscalPeriod[]
- if (initialCompanyId === company.id && initialPeriods !== undefined) {
- fetched = preparePeriods(initialPeriods, hideFuturePeriods)
- } else {
- const res = await fetch('/api/bookkeeping/fiscal-periods')
- if (!res.ok) {
- if (!cancelled) {
- setLoaded(true)
- onReady?.()
- }
- return
- }
- const { data } = await res.json()
- fetched = preparePeriods(data || [], hideFuturePeriods)
- }
- if (cancelled) return
+ if (!loaded || restoredForRef.current === company.id) return
+ restoredForRef.current = company.id
- setPeriods(fetched)
- setLoaded(true)
-
- // Restore last selection (same key as FiscalYearSelector so pages keep
- // their scope when the picker swaps in).
- //
- // requireExplicitChoice gates this WHOLE block, not individual branches:
- // every path in here ends in an unprompted onChange (restore, the
- // ALL_YEARS-stored fallback, newest-period, preferLatestEnded), and a
- // per-branch gate already missed one of them once. Nothing auto-fires;
- // the picker stays empty until a human picks.
- if (value === null && !requireExplicitChoice && !suppressAutoRestore && typeof window !== 'undefined') {
- if (preferLatestEnded) {
- // Filing surfaces: ignore the shared scope memory and open on the
- // most recently ended period (fetched is sorted newest-first).
- const today = new Date().toISOString().split('T')[0]
- const pick = fetched.find((p) => p.period_end < today) ?? fetched[0]
- if (pick) onChange(pick.id, pick)
- } else {
- const stored = window.localStorage.getItem(storageKeyPrefix + company.id)
- if (stored === ALL_YEARS_VALUE) {
- if (includeAllOption) onChange(null, null)
- else if (fetched.length > 0) onChange(fetched[0].id, fetched[0])
- } else if (stored && fetched.some((p) => p.id === stored)) {
- onChange(stored, fetched.find((p) => p.id === stored) ?? null)
- } else if (!includeAllOption && fetched.length > 0) {
- onChange(fetched[0].id, fetched[0])
- }
- }
- }
-
- onReady?.()
- })()
- return () => {
- cancelled = true
+ // Restore last selection (same key as FiscalYearSelector so pages keep
+ // their scope when the picker swaps in).
+ //
+ // requireExplicitChoice gates this WHOLE block, not individual branches:
+ // every path in here ends in an unprompted onChange (restore, the
+ // ALL_YEARS-stored fallback, newest-period, preferLatestEnded), and a
+ // per-branch gate already missed one of them once. Nothing auto-fires;
+ // the picker stays empty until a human picks.
+ if (value === null && !requireExplicitChoice && !suppressAutoRestore && typeof window !== 'undefined') {
+ const stored = window.localStorage.getItem(storageKeyPrefix + company.id)
+ const pick = resolveInitialFiscalScope(periods, stored, { includeAllOption, preferLatestEnded })
+ if (pick) onChange(pick.periodId, pick.period)
}
- // onReady is a lifecycle callback: fire once per load, not on parent
- // re-renders that re-create it.
+
+ onReady?.()
+ // onReady/onChange are lifecycle callbacks: fire once per load, not on
+ // parent re-renders that re-create them. `value` is read once at restore.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [company?.id, hideFuturePeriods, includeAllOption, preferLatestEnded, requireExplicitChoice, suppressAutoRestore, initialCompanyId, initialPeriods, storageKeyPrefix])
+ }, [company?.id, loaded, periods])
const handleChange = (id: string) => {
const nextId = id === ALL_YEARS_VALUE ? null : id
diff --git a/components/common/fiscal-year-storage.ts b/components/common/fiscal-year-storage.ts
new file mode 100644
index 00000000..d6ada222
--- /dev/null
+++ b/components/common/fiscal-year-storage.ts
@@ -0,0 +1,9 @@
+/**
+ * Persisted fiscal-year scope: the localStorage key prefix (companyId is
+ * appended) and the sentinel for an explicit "all years" choice. Lives in
+ * its own dependency-free module so lib/ code (the reference-data scope
+ * resolver) can import it without pulling a React component along.
+ * FiscalYearSelector re-exports both for existing importers.
+ */
+export const STORAGE_KEY_PREFIX = 'Accounted:fiscal-year:'
+export const ALL_YEARS_VALUE = '__all__'
diff --git a/components/transactions/BankSyncNowButton.tsx b/components/transactions/BankSyncNowButton.tsx
index fd038172..7176a608 100644
--- a/components/transactions/BankSyncNowButton.tsx
+++ b/components/transactions/BankSyncNowButton.tsx
@@ -15,6 +15,7 @@ import {
} from '@/components/ui/dropdown-menu'
import { createClient } from '@/lib/supabase/client'
import { notifyBankSyncUpdated } from '@/lib/transactions/bank-sync-signal'
+import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
import {
claimConnectionsLoad,
clearBusyConnection,
@@ -162,6 +163,9 @@ export function useBankSync() {
// Tell the neighbouring status chip to refetch so it doesn't keep showing
// the pre-sync "synced Nd ago" until a hard reload.
notifyBankSyncUpdated()
+ // The account chooser reads cash accounts from the session cache
+ // (lib/reference-data); a sync can change balances, so refetch them.
+ void invalidateReferenceData('ref:cash-accounts')
router.refresh()
} catch (error) {
toast({
diff --git a/lib/reference-data/__tests__/fiscal-scope.test.ts b/lib/reference-data/__tests__/fiscal-scope.test.ts
new file mode 100644
index 00000000..338aa4d4
--- /dev/null
+++ b/lib/reference-data/__tests__/fiscal-scope.test.ts
@@ -0,0 +1,89 @@
+import { describe, it, expect } from 'vitest'
+import { prepareFiscalPeriods, resolveInitialFiscalScope } from '../fiscal-scope'
+import { ALL_YEARS_VALUE } from '@/components/common/fiscal-year-storage'
+import type { FiscalPeriod } from '@/types'
+
+const period = (id: string, start: string, end: string) =>
+ ({ id, name: id, period_start: start, period_end: end }) as unknown as FiscalPeriod
+
+const p2024 = period('p2024', '2024-01-01', '2024-12-31')
+const p2025 = period('p2025', '2025-01-01', '2025-12-31')
+const p2026 = period('p2026', '2026-01-01', '2026-12-31')
+const p2027 = period('p2027', '2027-01-01', '2027-12-31')
+const TODAY = '2026-08-26'
+
+describe('prepareFiscalPeriods', () => {
+ it('sorts newest first and can hide periods that have not started', () => {
+ const all = prepareFiscalPeriods([p2024, p2027, p2026, p2025], false, TODAY)
+ expect(all.map((p) => p.id)).toEqual(['p2027', 'p2026', 'p2025', 'p2024'])
+ const started = prepareFiscalPeriods([p2024, p2027, p2026, p2025], true, TODAY)
+ expect(started.map((p) => p.id)).toEqual(['p2026', 'p2025', 'p2024'])
+ })
+
+ it('does not mutate the input', () => {
+ const input = [p2024, p2026]
+ prepareFiscalPeriods(input, false, TODAY)
+ expect(input.map((p) => p.id)).toEqual(['p2024', 'p2026'])
+ })
+})
+
+describe('resolveInitialFiscalScope', () => {
+ const periods = prepareFiscalPeriods([p2024, p2025, p2026], false, TODAY)
+
+ it('restores a persisted period that still exists', () => {
+ expect(resolveInitialFiscalScope(periods, 'p2025', { includeAllOption: true })).toEqual({
+ periodId: 'p2025',
+ period: p2025,
+ })
+ })
+
+ it('ignores a stale persisted id and falls back per the surface', () => {
+ expect(resolveInitialFiscalScope(periods, 'gone', { includeAllOption: true })).toBeNull()
+ expect(resolveInitialFiscalScope(periods, 'gone', { includeAllOption: false })).toEqual({
+ periodId: 'p2026',
+ period: p2026,
+ })
+ })
+
+ it('honours an explicit "all years" only where the surface allows it', () => {
+ expect(resolveInitialFiscalScope(periods, ALL_YEARS_VALUE, { includeAllOption: true })).toEqual({
+ periodId: null,
+ period: null,
+ })
+ expect(resolveInitialFiscalScope(periods, ALL_YEARS_VALUE, { includeAllOption: false })).toEqual({
+ periodId: 'p2026',
+ period: p2026,
+ })
+ })
+
+ it('with nothing stored: all-years surfaces stay unfiltered, others pick the newest', () => {
+ expect(resolveInitialFiscalScope(periods, null, { includeAllOption: true })).toBeNull()
+ expect(resolveInitialFiscalScope(periods, null, { includeAllOption: false })).toEqual({
+ periodId: 'p2026',
+ period: p2026,
+ })
+ })
+
+ it('preferLatestEnded opens on the most recently ended period and ignores the stored choice', () => {
+ expect(
+ resolveInitialFiscalScope(periods, 'p2024', { includeAllOption: false, preferLatestEnded: true, today: TODAY }),
+ ).toEqual({ periodId: 'p2025', period: p2025 })
+ })
+
+ it('preferLatestEnded falls back to the newest period when none has ended', () => {
+ const onlyCurrent = prepareFiscalPeriods([p2026], false, TODAY)
+ expect(
+ resolveInitialFiscalScope(onlyCurrent, null, { includeAllOption: false, preferLatestEnded: true, today: TODAY }),
+ ).toEqual({ periodId: 'p2026', period: p2026 })
+ })
+
+ it('returns null for an empty list on every path', () => {
+ expect(resolveInitialFiscalScope([], null, { includeAllOption: false })).toBeNull()
+ expect(resolveInitialFiscalScope([], ALL_YEARS_VALUE, { includeAllOption: false })).toBeNull()
+ expect(resolveInitialFiscalScope([], 'x', { includeAllOption: false, preferLatestEnded: true })).toBeNull()
+ expect(resolveInitialFiscalScope([], ALL_YEARS_VALUE, { includeAllOption: true })).toEqual({
+ periodId: null,
+ period: null,
+ })
+ })
+})
diff --git a/lib/reference-data/fiscal-scope.ts b/lib/reference-data/fiscal-scope.ts
new file mode 100644
index 00000000..acd4c058
--- /dev/null
+++ b/lib/reference-data/fiscal-scope.ts
@@ -0,0 +1,75 @@
+/**
+ * Initial fiscal-year scope resolution shared by FyPicker and
+ * FiscalYearSelector. Pure so the restore rules (persisted choice, "all
+ * years", newest started period, most recently ended period) can be tested
+ * without React, and so both pickers cannot drift apart again.
+ */
+
+import type { FiscalPeriod } from '@/types'
+import { ALL_YEARS_VALUE } from '@/components/common/fiscal-year-storage'
+
+export function todayIso(): string {
+ return new Date().toISOString().split('T')[0]
+}
+
+/** Newest first; optionally drops periods that have not started yet. */
+export function prepareFiscalPeriods(
+ periods: readonly FiscalPeriod[],
+ hideFuturePeriods: boolean,
+ today: string = todayIso(),
+): FiscalPeriod[] {
+ return periods
+ .filter((p) => !hideFuturePeriods || p.period_start <= today)
+ .sort((a, b) => b.period_start.localeCompare(a.period_start))
+}
+
+export interface FiscalScopeOptions {
+ /** Whether "all years" (null) is a valid selection on this surface. */
+ includeAllOption: boolean
+ /**
+ * Filing surfaces: ignore the persisted choice and open on the most
+ * recently ended period (only an ended year can be declared).
+ */
+ preferLatestEnded?: boolean
+ today?: string
+}
+
+export interface FiscalScopePick {
+ periodId: string | null
+ period: FiscalPeriod | null
+}
+
+/**
+ * What the picker should select on load, or null when nothing should be
+ * auto-selected. `periods` must already be prepared (newest first);
+ * `stored` is the raw persisted value for this company (or null).
+ */
+export function resolveInitialFiscalScope(
+ periods: readonly FiscalPeriod[],
+ stored: string | null,
+ options: FiscalScopeOptions,
+): FiscalScopePick | null {
+ const newest = periods[0] ?? null
+
+ if (options.preferLatestEnded) {
+ const today = options.today ?? todayIso()
+ const pick = periods.find((p) => p.period_end < today) ?? newest
+ return pick ? { periodId: pick.id, period: pick } : null
+ }
+
+ if (stored === ALL_YEARS_VALUE) {
+ if (options.includeAllOption) return { periodId: null, period: null }
+ return newest ? { periodId: newest.id, period: newest } : null
+ }
+
+ if (stored) {
+ const match = periods.find((p) => p.id === stored)
+ if (match) return { periodId: match.id, period: match }
+ }
+
+ if (!options.includeAllOption && newest) {
+ return { periodId: newest.id, period: newest }
+ }
+
+ return null
+}
diff --git a/scripts/checks/antipatterns-baseline.json b/scripts/checks/antipatterns-baseline.json
index 6690f40d..3dffb7fb 100644
--- a/scripts/checks/antipatterns-baseline.json
+++ b/scripts/checks/antipatterns-baseline.json
@@ -34,7 +34,7 @@
]
},
"rawReferenceFetch": {
- "count": 55,
+ "count": 51,
"files": [
"app/(dashboard)/assets/[id]/dispose/page.tsx",
"app/(dashboard)/bookkeeping/year-end/page.tsx",
@@ -47,7 +47,6 @@
"app/(dashboard)/salary/employees/[id]/page.tsx",
"app/(dashboard)/salary/runs/[id]/page.tsx",
"app/(dashboard)/supplier-invoices/[id]/page.tsx",
- "app/(dashboard)/transactions/page.tsx",
"components/articles/ArticleForm.tsx",
"components/bookkeeping/BookingTemplatePicker.tsx",
"components/bookkeeping/ChartOfAccounts.tsx",
@@ -55,12 +54,9 @@
"components/bookkeeping/CorrectionEntryDialog.tsx",
"components/bookkeeping/EditAccountDialog.tsx",
"components/bookkeeping/JournalEntryForm.tsx",
- "components/bookkeeping/JournalEntryList.tsx",
"components/bookkeeping/StrikeLinesDialog.tsx",
"components/bookkeeping/TemplateBookDialog.tsx",
"components/common/CashAccountSelector.tsx",
- "components/common/FiscalYearSelector.tsx",
- "components/common/FyPicker.tsx",
"components/dimensions/types.ts",
"components/extensions/general/ArcimMigrationWorkspace.tsx",
"components/extensions/general/BookDirectlyDialog.tsx",