* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
154 lines
6.6 KiB
TypeScript
154 lines
6.6 KiB
TypeScript
/**
|
|
* Pure helpers for proposing a new fiscal period (räkenskapsår) in the
|
|
* "Skapa räkenskapsår" dialog. Kept framework-free so they can be unit-tested
|
|
* in isolation (component code itself is not under test per the project's
|
|
* lib/ + app/api/ test scope).
|
|
*
|
|
* All date math runs in UTC. Local-time `Date` arithmetic combined with
|
|
* `toISOString()` shifts dates by the timezone offset (e.g. CET turns
|
|
* 2024-12-31 into 2025-12-30), which silently corrupts period boundaries.
|
|
*/
|
|
|
|
import type { FiscalPeriod } from '@/types'
|
|
import { addDaysIso as addDays } from '@/lib/dates/iso'
|
|
|
|
/** Minimal shape needed for the date math: `FiscalPeriod` satisfies it. */
|
|
type PeriodRange = Pick<FiscalPeriod, 'period_start' | 'period_end'>
|
|
|
|
/** A period plus its id, for resolving which räkenskapsår a view scopes to. */
|
|
type IdentifiablePeriod = Pick<FiscalPeriod, 'id' | 'period_start' | 'period_end'>
|
|
|
|
export interface SuggestedPeriod {
|
|
name: string
|
|
period_start: string
|
|
period_end: string
|
|
}
|
|
|
|
/**
|
|
* A fiscal-year name: `Räkenskapsår 2025`, or `Räkenskapsår 2024/2025` when it
|
|
* straddles two calendar years. Swedish by default to match the app's existing
|
|
* fiscal-year naming (Swedish-first); the field stays editable in the dialog.
|
|
*/
|
|
function periodName(start: string, end: string): string {
|
|
const startYear = Number(start.slice(0, 4))
|
|
const endYear = Number(end.slice(0, 4))
|
|
return startYear === endYear ? `Räkenskapsår ${startYear}` : `Räkenskapsår ${startYear}/${endYear}`
|
|
}
|
|
|
|
/**
|
|
* Suggest a fiscal period for the create dialog, given the date the user is
|
|
* trying to book and the company's existing periods. Three cases:
|
|
* - No periods yet → a calendar year around the entry date.
|
|
* - Entry date before the earliest period → a year ending the day before it (backfill).
|
|
* - Entry date inside an interior gap → a year filling the hole, starting the day
|
|
* after the left neighbour and capped so it never overlaps the right neighbour
|
|
* (a clean one-year hole yields exactly that year, e.g. 2025 between 2024 and 2026).
|
|
* - Otherwise → the next year chaining forward off the latest period.
|
|
*/
|
|
export function computeSuggestedPeriod(
|
|
entryDate: string,
|
|
periods: PeriodRange[],
|
|
): SuggestedPeriod {
|
|
if (periods.length === 0) {
|
|
// No periods at all: suggest a calendar year period around the entry date.
|
|
const year = entryDate.split('-')[0]
|
|
return { name: `Räkenskapsår ${year}`, period_start: `${year}-01-01`, period_end: `${year}-12-31` }
|
|
}
|
|
|
|
const sorted = [...periods].sort((a, b) => a.period_start.localeCompare(b.period_start))
|
|
const earliest = sorted[0]
|
|
const latest = sorted[sorted.length - 1]
|
|
|
|
if (entryDate < earliest.period_start) {
|
|
// Backward: end = day before earliest start, start = 12 months back, 1st of month.
|
|
const endStr = addDays(earliest.period_start, -1)
|
|
const start = new Date(endStr + 'T00:00:00Z')
|
|
start.setUTCMonth(start.getUTCMonth() - 11)
|
|
start.setUTCDate(1)
|
|
const startStr = start.toISOString().split('T')[0]
|
|
return { name: periodName(startStr, endStr), period_start: startStr, period_end: endStr }
|
|
}
|
|
|
|
// Interior gap: the entry date sits between the earliest and latest period but is
|
|
// not contained by any existing period.
|
|
const containing = sorted.find((p) => p.period_start <= entryDate && entryDate <= p.period_end)
|
|
if (!containing && entryDate <= latest.period_end) {
|
|
const leftNeighbour = [...sorted].reverse().find((p) => p.period_end < entryDate)!
|
|
const rightNeighbour = sorted.find((p) => p.period_start > entryDate)!
|
|
|
|
const startStr = addDays(leftNeighbour.period_end, 1)
|
|
|
|
// Tentative end: 12 months after start, last day of the prior month.
|
|
const end = new Date(startStr + 'T00:00:00Z')
|
|
end.setUTCMonth(end.getUTCMonth() + 12)
|
|
end.setUTCDate(0)
|
|
let endStr = end.toISOString().split('T')[0]
|
|
|
|
// Cap at the day before the right neighbour starts so we never overlap it.
|
|
const gapEnd = addDays(rightNeighbour.period_start, -1)
|
|
if (gapEnd < endStr) endStr = gapEnd
|
|
|
|
return { name: periodName(startStr, endStr), period_start: startStr, period_end: endStr }
|
|
}
|
|
|
|
// Forward: start = day after latest end, end = 12 months later (last day of month).
|
|
const startStr = addDays(latest.period_end, 1)
|
|
const end = new Date(startStr + 'T00:00:00Z')
|
|
end.setUTCMonth(end.getUTCMonth() + 12)
|
|
end.setUTCDate(0)
|
|
const endStr = end.toISOString().split('T')[0]
|
|
return { name: periodName(startStr, endStr), period_start: startStr, period_end: endStr }
|
|
}
|
|
|
|
/**
|
|
* Seed date for the settings "Skapa räkenskapsår" dialog. Prefers the start of the
|
|
* earliest gap between consecutive periods so the dialog proposes a missing year
|
|
* (e.g. 2025 between 2024 and 2026) instead of jumping to the next forward year.
|
|
* Falls back to the day after the latest period ends, or today when there are none.
|
|
*/
|
|
export function suggestSeedDate(periods: PeriodRange[], today: string): string {
|
|
if (periods.length === 0) return today
|
|
|
|
const sorted = [...periods].sort((a, b) => a.period_start.localeCompare(b.period_start))
|
|
|
|
for (let i = 0; i < sorted.length - 1; i++) {
|
|
const gapStart = addDays(sorted[i].period_end, 1)
|
|
if (gapStart < sorted[i + 1].period_start) return gapStart
|
|
}
|
|
|
|
return addDays(sorted[sorted.length - 1].period_end, 1)
|
|
}
|
|
|
|
/**
|
|
* Resolve which fiscal period a period-scoped view (e.g. the verifikat list)
|
|
* should default to: the räkenskapsår the user is currently in.
|
|
*
|
|
* Verifikationsnummer run as an unbroken series *per räkenskapsår* (BFL 5 kap
|
|
* 7§), so the same number (e.g. A42) legitimately recurs once per year. Showing
|
|
* every year at once makes those look like duplicates and makes a bare "A42"
|
|
* reference ambiguous: a period-oriented view should land on a single year.
|
|
*
|
|
* Resolution, given `today` (YYYY-MM-DD):
|
|
* 1. The period that contains today.
|
|
* 2. Else the most recent period that has already started (period_start ≤ today):
|
|
* covers a gap after the last year before the next one is created.
|
|
* 3. Else the earliest period (a company whose only/first year is still upcoming).
|
|
* 4. Else null (no periods at all → caller falls back to "all years").
|
|
*/
|
|
export function resolveCurrentPeriodId(
|
|
periods: IdentifiablePeriod[],
|
|
today: string,
|
|
): string | null {
|
|
if (periods.length === 0) return null
|
|
|
|
const containing = periods.find((p) => p.period_start <= today && today <= p.period_end)
|
|
if (containing) return containing.id
|
|
|
|
const started = periods
|
|
.filter((p) => p.period_start <= today)
|
|
.sort((a, b) => b.period_start.localeCompare(a.period_start))
|
|
if (started.length > 0) return started[0].id
|
|
|
|
return [...periods].sort((a, b) => a.period_start.localeCompare(b.period_start))[0].id
|
|
}
|