feat(transactions): filter /transactions by rakenskapsar and kvartal (#1545)
* feat(transactions): filter /transactions by rakenskapsar and kvartal User request: booking a specific period (including brutet rakenskapsar, e.g. July-June) meant scrolling past every other year's transactions. - New FyPicker chip in the toolbar scopes both the inbox and history views to a fiscal year; quarter chips (Q1-Q4, fiscal-year aligned) appear once a year is selected. Clicking the active quarter widens back to the year. - Bounds are pushed into the Supabase queries (window, pending backlog, badge count, load-more) so pagination and the Att bokfora count stay consistent with the visible list; skattekonto rows are bounded client-side. - Scope persists under a page-local localStorage key, deliberately separate from the shared report scope so a year picked on a report page never silently hides pending inbox rows. - lib/transactions/period-filter.ts derives quarter bounds from fiscal period dates (handles brutet, shortened and extended years); unit tested. - FyPicker gains an optional storageKeyPrefix prop; default unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): never hide pending rows behind the period filter Swedish accounting review on PR #1545: scoping the pending-backlog fetch and badge count to the period made unbooked rows outside the selected year vanish from the inbox worklist (BFL 5 kap: pending affarshandelser must stay visible until booked). - Pending-backlog fetch and the DB pending count are unscoped again; only the history window pages server-side within the period. - The inbox applies the period client-side over the complete backlog; the tab badge counts pending rows inside the scope. - When pending rows (bank or skattekonto) fall outside the scope, the footer says how many and offers Visa alla, which clears the filter and its persisted value. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): guard list fetches against stale cross-scope responses CodeRabbit on PR #1545: - fetchTransactions/loadMoreTransactions now carry a fetch generation; a response applies only if no newer fetch (scope change, realtime refresh, load-more) started meanwhile, so a slow pre-filter request can no longer overwrite the active period scope's window, paging offsets, or loading skeleton. - FyPicker restore effect includes storageKeyPrefix in its deps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): label quarter chips as fiscal-year quarters Swedish accounting review note on PR #1545: Q1-Q4 follow the company's rakenskapsar, which on a brutet rakenskapsar differs from the calendar quarters that momsdeklaration periods use. Say so in the group's aria-label and hover title so the chips are not mistaken for VAT periods. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a07dcf4a55
commit
b9bf60234d
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
quarterBounds,
|
||||
resolvePeriodBounds,
|
||||
isWithinBounds,
|
||||
QUARTERS,
|
||||
} from '@/lib/transactions/period-filter'
|
||||
|
||||
const calendarYear = { period_start: '2026-01-01', period_end: '2026-12-31' }
|
||||
// Brutet rakenskapsar: the reporter's July-June case.
|
||||
const brokenYear = { period_start: '2025-07-01', period_end: '2026-06-30' }
|
||||
// Shortened first year (6 months).
|
||||
const shortYear = { period_start: '2026-01-01', period_end: '2026-06-30' }
|
||||
// Extended first year (18 months).
|
||||
const extendedYear = { period_start: '2025-01-01', period_end: '2026-06-30' }
|
||||
|
||||
describe('quarterBounds', () => {
|
||||
it('splits a calendar fiscal year into calendar quarters', () => {
|
||||
expect(quarterBounds(calendarYear, 1)).toEqual({ start: '2026-01-01', end: '2026-03-31' })
|
||||
expect(quarterBounds(calendarYear, 2)).toEqual({ start: '2026-04-01', end: '2026-06-30' })
|
||||
expect(quarterBounds(calendarYear, 3)).toEqual({ start: '2026-07-01', end: '2026-09-30' })
|
||||
expect(quarterBounds(calendarYear, 4)).toEqual({ start: '2026-10-01', end: '2026-12-31' })
|
||||
})
|
||||
|
||||
it('follows the fiscal year for a brutet rakenskapsar (July-June)', () => {
|
||||
expect(quarterBounds(brokenYear, 1)).toEqual({ start: '2025-07-01', end: '2025-09-30' })
|
||||
expect(quarterBounds(brokenYear, 2)).toEqual({ start: '2025-10-01', end: '2025-12-31' })
|
||||
expect(quarterBounds(brokenYear, 3)).toEqual({ start: '2026-01-01', end: '2026-03-31' })
|
||||
expect(quarterBounds(brokenYear, 4)).toEqual({ start: '2026-04-01', end: '2026-06-30' })
|
||||
})
|
||||
|
||||
it('returns null for quarters beyond a shortened period', () => {
|
||||
expect(quarterBounds(shortYear, 1)).toEqual({ start: '2026-01-01', end: '2026-03-31' })
|
||||
expect(quarterBounds(shortYear, 2)).toEqual({ start: '2026-04-01', end: '2026-06-30' })
|
||||
expect(quarterBounds(shortYear, 3)).toBeNull()
|
||||
expect(quarterBounds(shortYear, 4)).toBeNull()
|
||||
})
|
||||
|
||||
it('clamps a quarter end that would pass period_end', () => {
|
||||
const fiveMonths = { period_start: '2026-01-01', period_end: '2026-05-31' }
|
||||
expect(quarterBounds(fiveMonths, 2)).toEqual({ start: '2026-04-01', end: '2026-05-31' })
|
||||
})
|
||||
|
||||
it('lets Q4 absorb the tail of an extended fiscal year', () => {
|
||||
expect(quarterBounds(extendedYear, 4)).toEqual({ start: '2025-10-01', end: '2026-06-30' })
|
||||
})
|
||||
|
||||
it('covers every day of a regular period across the four quarters', () => {
|
||||
for (const period of [calendarYear, brokenYear]) {
|
||||
const bounds = QUARTERS.map((q) => quarterBounds(period, q))
|
||||
expect(bounds[0]?.start).toBe(period.period_start)
|
||||
expect(bounds[3]?.end).toBe(period.period_end)
|
||||
for (let i = 1; i < 4; i++) {
|
||||
const prevEnd = bounds[i - 1]?.end
|
||||
const nextStart = bounds[i]?.start
|
||||
expect(prevEnd).toBeDefined()
|
||||
expect(nextStart).toBeDefined()
|
||||
// Next quarter starts the day after the previous one ends.
|
||||
const followingDay = new Date(`${prevEnd}T00:00:00Z`)
|
||||
followingDay.setUTCDate(followingDay.getUTCDate() + 1)
|
||||
expect(nextStart).toBe(followingDay.toISOString().slice(0, 10))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('handles a period start that is not the first of a month', () => {
|
||||
const midMonth = { period_start: '2026-01-15', period_end: '2027-01-14' }
|
||||
expect(quarterBounds(midMonth, 1)).toEqual({ start: '2026-01-15', end: '2026-04-14' })
|
||||
expect(quarterBounds(midMonth, 2)).toEqual({ start: '2026-04-15', end: '2026-07-14' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolvePeriodBounds', () => {
|
||||
it('returns null without a period', () => {
|
||||
expect(resolvePeriodBounds(null, null)).toBeNull()
|
||||
expect(resolvePeriodBounds(null, 2)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the whole period without a quarter', () => {
|
||||
expect(resolvePeriodBounds(brokenYear, null)).toEqual({
|
||||
start: '2025-07-01',
|
||||
end: '2026-06-30',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns quarter bounds with a quarter', () => {
|
||||
expect(resolvePeriodBounds(calendarYear, 3)).toEqual({
|
||||
start: '2026-07-01',
|
||||
end: '2026-09-30',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for a quarter outside the period', () => {
|
||||
expect(resolvePeriodBounds(shortYear, 4)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isWithinBounds', () => {
|
||||
const bounds = { start: '2026-01-01', end: '2026-03-31' }
|
||||
|
||||
it('accepts everything when no bounds are set', () => {
|
||||
expect(isWithinBounds('1999-01-01', null)).toBe(true)
|
||||
})
|
||||
|
||||
it('includes both endpoints', () => {
|
||||
expect(isWithinBounds('2026-01-01', bounds)).toBe(true)
|
||||
expect(isWithinBounds('2026-03-31', bounds)).toBe(true)
|
||||
})
|
||||
|
||||
it('excludes dates outside the bounds', () => {
|
||||
expect(isWithinBounds('2025-12-31', bounds)).toBe(false)
|
||||
expect(isWithinBounds('2026-04-01', bounds)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
|
||||
/** Inclusive ISO date bounds (yyyy-MM-dd) for a period filter. */
|
||||
export interface PeriodBounds {
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
|
||||
export type Quarter = 1 | 2 | 3 | 4
|
||||
|
||||
export const QUARTERS: Quarter[] = [1, 2, 3, 4]
|
||||
|
||||
type PeriodDates = Pick<FiscalPeriod, 'period_start' | 'period_end'>
|
||||
|
||||
function toIso(year: number, month: number, day: number): string {
|
||||
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Month arithmetic on ISO date strings. Day-of-month overflow clamps to the
|
||||
// last day of the target month (2026-01-31 + 1 month = 2026-02-28), matching
|
||||
// how fiscal quarters are counted from the period start.
|
||||
function addMonths(iso: string, months: number): string {
|
||||
const [y, m, d] = iso.split('-').map(Number)
|
||||
const total = y * 12 + (m - 1) + months
|
||||
const year = Math.floor(total / 12)
|
||||
const monthIndex = total - year * 12
|
||||
const lastDay = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate()
|
||||
return toIso(year, monthIndex + 1, Math.min(d, lastDay))
|
||||
}
|
||||
|
||||
function addDays(iso: string, days: number): string {
|
||||
const date = new Date(`${iso}T00:00:00Z`)
|
||||
date.setUTCDate(date.getUTCDate() + days)
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* Date bounds for one quarter of a fiscal period. Quarters follow the fiscal
|
||||
* year, not the calendar: Q1 starts at period_start, so a brutet rakenskapsar
|
||||
* (e.g. July to June) gets Q1 = Jul-Sep. Rules for irregular period lengths:
|
||||
* - A quarter whose start falls after period_end does not exist (returns
|
||||
* null); the caller disables that choice.
|
||||
* - Q4 always runs to period_end, so an extended first year (up to 18 months)
|
||||
* keeps every transaction reachable through some quarter.
|
||||
* - A quarter end never extends past period_end.
|
||||
*/
|
||||
export function quarterBounds(period: PeriodDates, quarter: Quarter): PeriodBounds | null {
|
||||
const start = addMonths(period.period_start, (quarter - 1) * 3)
|
||||
if (start > period.period_end) return null
|
||||
if (quarter === 4) return { start, end: period.period_end }
|
||||
const end = addDays(addMonths(period.period_start, quarter * 3), -1)
|
||||
return { start, end: end > period.period_end ? period.period_end : end }
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds for the active period filter: the whole fiscal period, one of its
|
||||
* quarters, or null when no filter is applied (or the quarter does not exist
|
||||
* within the period).
|
||||
*/
|
||||
export function resolvePeriodBounds(
|
||||
period: PeriodDates | null,
|
||||
quarter: Quarter | null,
|
||||
): PeriodBounds | null {
|
||||
if (!period) return null
|
||||
if (quarter === null) return { start: period.period_start, end: period.period_end }
|
||||
return quarterBounds(period, quarter)
|
||||
}
|
||||
|
||||
/** True when an ISO date falls inside the bounds. No bounds = everything. */
|
||||
export function isWithinBounds(date: string, bounds: PeriodBounds | null): boolean {
|
||||
if (!bounds) return true
|
||||
return date >= bounds.start && date <= bounds.end
|
||||
}
|
||||
Reference in New Issue
Block a user