Files
accounted/lib/transactions/period-filter.ts
T
MattssonandClaude Fable 5 b9bf60234d 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>
2026-08-13 01:21:31 +02:00

74 lines
2.9 KiB
TypeScript

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
}