Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
160 lines
6.8 KiB
TypeScript
160 lines
6.8 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'
|
|
|
|
/** 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
|
|
}
|
|
|
|
/** Add `days` to a YYYY-MM-DD date string, returning a YYYY-MM-DD string. */
|
|
function addDays(date: string, days: number): string {
|
|
const d = new Date(date + 'T00:00:00Z')
|
|
d.setUTCDate(d.getUTCDate() + days)
|
|
return d.toISOString().split('T')[0]
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|