Let a planned day say why it was placed

Every funded day the allocator places already carries a machine-readable
`reason` code — the constants block in allocation_service.py even says it
exists "so the UI can explain itself" — but no user has ever seen one.
The calendar showed what was decided, never why, which is the one question
this product exists to answer.

Clicking a planned day now also raises the existing calendar toast with a
plain-Swedish (and English) sentence for that date and parent: a pinned day
says the user chose it, an allocator-placed day explains the placement, and
anything unrecognised — or a session where no proposal has landed yet —
shows nothing rather than a raw code.

The reason is deliberately not persisted: it is stripped at apply and
`planned_leave_day` has no column for it, so the source is the live
in-memory proposal, merged last-writer-wins from Calendar's own sync
response and PlanSyncProvider's latest. Before the first sync of a session
there is simply no explanation to show.

Also extends the backend's field-parity walk to ProposedDay, which it did
not cover — the reason could have been dropped from the wire without a
single test failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 07:41:44 +02:00
parent fc90cf9a5f
commit 1a69221040
9 changed files with 384 additions and 1 deletions
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { reasonMessageKey } from './planReasons'
import { translate } from '@/i18n'
const ALL_REASON_CODES = [
'free_weekend',
'free_red_day',
'vacation_wrap_red_day',
'vacation_wrap_weekend',
'fp_interior',
'fp_lagsta_stretch',
'swap_first_half',
'swap_second_half',
'dubbeldag',
'fp_red_day',
]
describe('reasonMessageKey', () => {
it('maps every one of the allocator\'s 10 reason codes to a planReasons.* key', () => {
for (const code of ALL_REASON_CODES) {
const key = reasonMessageKey(code)
expect(key).not.toBeNull()
expect(key).toBe(`planReasons.${code}`)
}
})
it('resolves every mapped key to a real, non-empty string in both locales', () => {
for (const code of ALL_REASON_CODES) {
const key = reasonMessageKey(code)
expect(key).not.toBeNull()
if (!key) continue
expect(translate('sv', key)).not.toBe(key)
expect(translate('en', key)).not.toBe(key)
expect(translate('sv', key).length).toBeGreaterThan(0)
expect(translate('en', key).length).toBeGreaterThan(0)
}
})
it('returns null for an unrecognised reason code', () => {
expect(reasonMessageKey('some_future_reason_not_yet_mapped')).toBeNull()
})
it('returns null for an empty reason', () => {
expect(reasonMessageKey('')).toBeNull()
})
})
+36
View File
@@ -0,0 +1,36 @@
/**
* Maps an allocator `reason` code (`ProposedDay.reason` — the
* `REASON_*` constants in `backend/app/services/allocation_service.py:62-83`,
* §3.5, "machine-readable so the UI can explain itself") to the
* `planReasons.<code>` translation key that explains it in the calendar
* toast (see `Calendar.tsx`'s `handleBandClick`).
*
* Follows `frontend/src/utils/rejectionMessages.ts`'s precedent: an
* exhaustive `Record` from the backend-defined code to a `TranslationKey`,
* so an unrecognised or empty reason returns `null` rather than ever
* putting a raw code on screen. Every one of the allocator's 10 reason
* codes has an entry here even though only the non-free ones are reachable
* in practice (a free day renders no band, so it can never be clicked) —
* kept exhaustive so the map can't quietly fall behind the backend's list.
*/
import type { TranslationKey } from '@/i18n'
const REASON_KEYS: Record<string, TranslationKey> = {
free_weekend: 'planReasons.free_weekend',
free_red_day: 'planReasons.free_red_day',
vacation_wrap_red_day: 'planReasons.vacation_wrap_red_day',
vacation_wrap_weekend: 'planReasons.vacation_wrap_weekend',
fp_interior: 'planReasons.fp_interior',
fp_lagsta_stretch: 'planReasons.fp_lagsta_stretch',
swap_first_half: 'planReasons.swap_first_half',
swap_second_half: 'planReasons.swap_second_half',
dubbeldag: 'planReasons.dubbeldag',
fp_red_day: 'planReasons.fp_red_day',
}
/** Empty or unrecognised `reason` -> `null`; callers (`Calendar.tsx`) render
* nothing rather than a raw code or a generic "unknown" string. */
export function reasonMessageKey(reason: string): TranslationKey | null {
if (!reason) return null
return REASON_KEYS[reason] ?? null
}