Files
accounted/lib/salary/shift-premium-engine.ts
T
Jakob Wennberg ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
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>
2026-07-04 15:58:06 +02:00

320 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Shift-premium engine: pure functions that turn worked-day shifts plus
* configured premium rules into salary line items.
*
* Boundary rules:
* - Worked-day rows with explicit start_time/end_time use the exact window
* to intersect rule windows.
* - Worked-day rows missing one or both times fall back to a default day
* shift of 08:00-17:00 (`DEFAULT_SHIFT_*`). This is the legacy hours-only
* row shape: those days were always assumed to be plain weekday office
* hours, so pure-night/pure-weekend rules will not match.
* - Windows where end_time <= start_time are treated as wrapping past
* midnight (e.g. 22:00-06:00 covers 22:00-24:00 the same day and
* 00:00-06:00 the next day). Both halves are intersected separately.
*
* Overlap resolution:
* - Multiple active rules may apply to the same minute of a shift. The
* engine prefers the rule with the higher `priority`; ties are broken by
* higher `premium_percent`. Each minute is awarded to exactly one rule,
* so totals never double-count.
*
* The engine is intentionally side-effect-free. The orchestrator
* (run-calculation.ts) fetches the rules, runs `computePremiumLines`, and
* persists the result as salary_line_items.
*/
import type { ShiftPremiumRule, ShiftPremiumItemType } from '@/types'
import { isSwedishHolidayISO } from '@/lib/tax/swedish-holidays'
// ============================================================
// Types
// ============================================================
export interface WorkedDayShift {
/** ISO date (YYYY-MM-DD). */
work_date: string
/** Total worked hours (used as a sanity cap). */
hours: number
/** Optional explicit shift start ('HH:MM' or 'HH:MM:SS'). */
start_time?: string | null
/** Optional explicit shift end ('HH:MM' or 'HH:MM:SS'). */
end_time?: string | null
}
export interface ShiftPremiumLineItem {
itemType: ShiftPremiumItemType
/** Description rendered on the payslip and verifikat. */
description: string
/** Date the premium applies to (the worked day). */
workDate: string
/** Hours actually covered by the winning rule. */
hours: number
/** Premium amount = baseHourlyRate × hours × premium_percent / 100. */
amount: number
/** Rule id used (for traceability/debugging). */
sourceRuleId: string
}
// ============================================================
// Constants
// ============================================================
/** Total minutes in a 24h day. */
const MINUTES_PER_DAY = 24 * 60
/** Fallback shift for worked-day rows that don't carry explicit times. */
const DEFAULT_SHIFT_START_MIN = 8 * 60 // 08:00
const DEFAULT_SHIFT_END_MIN = 17 * 60 // 17:00
// ============================================================
// Helpers
// ============================================================
/** Round to 2 decimals (CLAUDE.md monetary precision rule). */
function r(x: number): number {
return Math.round(x * 100) / 100
}
/** Parse 'HH:MM' or 'HH:MM:SS' (or 'HH:MM:SS.ffffff') to minutes-since-midnight. */
function parseTimeToMinutes(time: string): number {
const parts = time.split(':')
if (parts.length < 2) {
throw new Error(`Invalid time format: ${time}`)
}
const h = parseInt(parts[0], 10)
const m = parseInt(parts[1], 10)
if (Number.isNaN(h) || Number.isNaN(m)) {
throw new Error(`Invalid time format: ${time}`)
}
return h * 60 + m
}
/** ISO weekday (1 = Mon … 7 = Sun) from a YYYY-MM-DD string. */
function isoWeekdayFromDate(workDate: string): number {
// Parse as UTC to avoid the user's local timezone shifting the day.
const [y, m, d] = workDate.split('-').map((x) => parseInt(x, 10))
const day = new Date(Date.UTC(y, m - 1, d)).getUTCDay() // Sun=0…Sat=6
return day === 0 ? 7 : day
}
/** Shift a weekday by N days, staying in ISO 1..7. */
function shiftWeekday(weekday: number, offset: number): number {
const next = ((weekday - 1 + offset) % 7 + 7) % 7
return next + 1
}
/**
* Resolve a shift's occupied minutes into segments keyed by ISO weekday.
* Returns one segment per occupied weekday. Wrap-past-midnight shifts return
* two entries.
*/
interface DaySegment {
weekday: number
startMin: number
endMin: number
}
function resolveShiftSegments(shift: WorkedDayShift): DaySegment[] {
const shiftWeekdayBase = isoWeekdayFromDate(shift.work_date)
const hasStart = !!shift.start_time
const hasEnd = !!shift.end_time
if (!hasStart || !hasEnd) {
return [
{
weekday: shiftWeekdayBase,
startMin: DEFAULT_SHIFT_START_MIN,
endMin: DEFAULT_SHIFT_END_MIN,
},
]
}
const startMin = parseTimeToMinutes(shift.start_time!)
const endMin = parseTimeToMinutes(shift.end_time!)
if (endMin > startMin) {
return [{ weekday: shiftWeekdayBase, startMin, endMin }]
}
// Wraps past midnight (e.g. 22:00 -> 06:00). Split at 24:00.
return [
{ weekday: shiftWeekdayBase, startMin, endMin: MINUTES_PER_DAY },
{ weekday: shiftWeekday(shiftWeekdayBase, 1), startMin: 0, endMin },
]
}
/**
* Resolve a rule's coverage windows into per-weekday segments.
* Returns one entry per (primary weekday in rule.day_of_week × time half).
* A rule whose end_time <= start_time wraps midnight; the second half then
* lands on the weekday AFTER each listed primary day.
*/
function resolveRuleSegments(rule: ShiftPremiumRule): DaySegment[] {
const startMin = parseTimeToMinutes(rule.start_time)
const endMin = parseTimeToMinutes(rule.end_time)
const segments: DaySegment[] = []
for (const primaryDay of rule.day_of_week) {
if (endMin > startMin) {
segments.push({ weekday: primaryDay, startMin, endMin })
} else if (startMin === endMin) {
// 00:00-00:00 special case: full 24h coverage on the primary day only.
segments.push({ weekday: primaryDay, startMin: 0, endMin: MINUTES_PER_DAY })
} else {
// Wrap past midnight.
segments.push({ weekday: primaryDay, startMin, endMin: MINUTES_PER_DAY })
segments.push({ weekday: shiftWeekday(primaryDay, 1), startMin: 0, endMin })
}
}
return segments
}
function ruleAppliesToEmployee(rule: ShiftPremiumRule, employeeId: string): boolean {
if (rule.applies_to_all_employees) return true
return rule.applies_to_employee_ids.includes(employeeId)
}
/** Total dominance score (priority outweighs premium_percent). */
function ruleScore(rule: ShiftPremiumRule): number {
return rule.priority * 1_000_000 + rule.premium_percent
}
// ============================================================
// Public API
// ============================================================
export interface ComputePremiumLinesArgs {
employeeId: string
baseHourlyRate: number
workedDays: WorkedDayShift[]
rules: ShiftPremiumRule[]
}
const DEFAULT_DESCRIPTIONS: Record<ShiftPremiumItemType, string> = {
overtime_50: 'Övertid 50 %',
overtime_100: 'Övertid 100 %',
ob_weekday_evening: 'OB vardag kväll',
ob_weekend: 'OB helg',
ob_night: 'OB natt',
ob_holiday: 'OB helgdag',
}
/**
* Compute premium line items for a single employee over a set of worked days.
*
* Algorithm:
* 1. Resolve each shift into per-weekday segments (one or two if wraps).
* 2. For each segment, gather every rule-segment whose weekday matches.
* 3. Split the segment at every rule boundary, then award each sub-interval
* to the single highest-scoring rule that fully covers it.
* 4. Aggregate (work_date × rule_id) → minutes; convert to hours + amount.
*/
export function computePremiumLines(args: ComputePremiumLinesArgs): ShiftPremiumLineItem[] {
const { employeeId, baseHourlyRate, workedDays, rules } = args
if (baseHourlyRate <= 0 || workedDays.length === 0 || rules.length === 0) {
return []
}
const applicable = rules.filter((rule) => rule.is_active && ruleAppliesToEmployee(rule, employeeId))
if (applicable.length === 0) return []
// Aggregate (workDate × ruleId) → minutes.
const aggregates = new Map<
string,
{ rule: ShiftPremiumRule; workDate: string; minutes: number }
>()
for (const shift of workedDays) {
if (shift.hours <= 0) continue
const shiftSegments = resolveShiftSegments(shift)
// Holiday status drives ob_holiday gating. A rule with item_type === 'ob_holiday'
// only fires when the worked day is a Swedish public holiday: day_of_week alone
// is not enough (a regular Sunday is not a helgdag, Midsommarafton on a Tuesday is).
const isHoliday = isSwedishHolidayISO(shift.work_date)
for (const seg of shiftSegments) {
// Collect rule-segments intersecting this weekday.
const candidates: Array<{
rule: ShiftPremiumRule
startMin: number
endMin: number
}> = []
for (const rule of applicable) {
if (rule.item_type === 'ob_holiday' && !isHoliday) continue
for (const ruleSeg of resolveRuleSegments(rule)) {
if (ruleSeg.weekday !== seg.weekday) continue
const startMin = Math.max(seg.startMin, ruleSeg.startMin)
const endMin = Math.min(seg.endMin, ruleSeg.endMin)
if (endMin <= startMin) continue
candidates.push({ rule, startMin, endMin })
}
}
if (candidates.length === 0) continue
// Build boundary set inside the shift segment.
const boundaries = new Set<number>([seg.startMin, seg.endMin])
for (const cand of candidates) {
boundaries.add(cand.startMin)
boundaries.add(cand.endMin)
}
const sorted = [...boundaries].sort((a, b) => a - b)
for (let i = 0; i < sorted.length - 1; i++) {
const subStart = sorted[i]
const subEnd = sorted[i + 1]
if (subEnd <= subStart) continue
if (subStart < seg.startMin || subEnd > seg.endMin) continue
// Award this sub-interval to the highest-scoring candidate covering it.
let winner: ShiftPremiumRule | null = null
for (const cand of candidates) {
if (cand.startMin > subStart || cand.endMin < subEnd) continue
if (!winner || ruleScore(cand.rule) > ruleScore(winner)) {
winner = cand.rule
}
}
if (!winner) continue
const minutes = subEnd - subStart
const key = `${shift.work_date}::${winner.id}`
const existing = aggregates.get(key)
if (existing) {
existing.minutes += minutes
} else {
aggregates.set(key, { rule: winner, workDate: shift.work_date, minutes })
}
}
}
}
const lineItems: ShiftPremiumLineItem[] = []
for (const agg of aggregates.values()) {
const hours = r(agg.minutes / 60)
if (hours <= 0) continue
const amount = r(baseHourlyRate * hours * (agg.rule.premium_percent / 100))
if (amount <= 0) continue
const fallback = DEFAULT_DESCRIPTIONS[agg.rule.item_type]
const desc = agg.rule.name
? `${agg.rule.name} (${agg.workDate}, ${hours} h)`
: `${fallback} (${agg.workDate}, ${hours} h)`
lineItems.push({
itemType: agg.rule.item_type,
description: desc,
workDate: agg.workDate,
hours,
amount,
sourceRuleId: agg.rule.id,
})
}
lineItems.sort((a, b) => {
if (a.workDate !== b.workDate) return a.workDate < b.workDate ? -1 : 1
return a.itemType < b.itemType ? -1 : 1
})
return lineItems
}