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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user