Finish the redesign's edges: auth page, modals, dead code, docs

The sign-in page becomes a plain centred column with the brand mark
and a segmented login/register switch, modals lose their last inner
boxes, the red-days list collapses every year but the current one,
and the retired components' CSS, an unused list component and 23
unreferenced translation keys are removed. ARCHITECTURE.md documents
the Almanacka styling conventions and the per-year balance rule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
This commit is contained in:
2026-09-20 07:00:22 +02:00
parent eafa32e6b0
commit 0081f60b06
28 changed files with 326 additions and 745 deletions
+57
View File
@@ -89,6 +89,40 @@ end-to-end specs, run separately from Vitest unit tests.
Run `git ls-files frontend/src` for the current, authoritative file list.
#### 2.2.1 Styling — the Almanacka design system
All design tokens (colour, spacing, type scale, radii, shadow) live as CSS custom properties in
`src/styles/global.css` only — a style guard (`src/styles/styles.test.ts`) fails the build on any
hex/rgba, px font-size, or `@media` query found outside `global.css`/`responsive.css`. Everything
else composes those tokens: `src/styles/primitives.css` holds cross-page primitives (`.btn`,
`.field`, `.card`, `.modal__*`, `.segmented`, `.disclosure`, `.skeleton`, `.alert`, `.badge`), and
each component/page owns a co-located `*.css` file for its own layout.
One accent (ink-blue) drives CTAs, focus rings, the active nav item, "today" and selection — there
is no second "success" colour. Type is self-hosted Schibsted Grotesk (`public/fonts/`, woff2, no
Google Fonts request — GDPR), scale 12/14/16/20/28/40 with nothing below 12px. The calendar ("the
Almanacka") tells day states apart by numeral ink and a small badge letter rather than cell fill —
red day → danger ink + "R", klämdag → warning ink + "K", today → filled accent circle — and tells
leave types apart by fill/ink/outline (vacation = accent tint, sjukpenning = dark fill, lägsta =
white + hatched outline), never by hue alone. Icons come from a single `Icon` component (inline SVG
symbols) — no emoji, no icon font. See `captures/redesign/design-system.md` for the full token
table and invariants (gitignored — a design scratch doc, not part of the shipped app).
Two recurring structural primitives replace what used to be ad hoc boxes: `.disclosure` (a
`<details>`/`<summary>` row with a hairline, not a bordered card — used by the dashboard's "Fler
detaljer", each dashboard year row, and Settings' per-year red-days list) and `.segmented` (a
pill-style two-or-more-option toggle — the SV/EN `LanguageToggle`, the auth page's Log in/Create
account tabs). Loading states go through `.skeleton` shimmer blocks rendered by a shared
`PageSkeleton` component (`src/components/common/`), which each page (`Dashboard`, `Calendar`,
`Settings`) renders inside its own `<Layout>` while its first fetch is in flight, so every route
gets the same loading treatment for free instead of each page inventing its own spinner.
Parental-leave balance entry has exactly one home: `BalanceFlow` (`src/components/balance/`), used
from Settings' "Saldon" panel — the dashboard no longer has its own balance-entry UI, only a read
of the latest saved figures. `BalanceFlow` wraps `ParentBalanceForm` (the modal that actually asks
for sjukpenningnivå/lägstanivå/dubbeldagar left) with the per-member status row (source +
freshness) that used to be duplicated per page.
---
### 2.3 Data Acquisition: Scraping (primary) + Manual Fallback
@@ -797,6 +831,29 @@ The dashboard's "projection" endpoint (`/api/analytics/{workspace_id}/projection
Edge cases the calculation must handle: child not yet born (plan from due date), reserved days (the 90 non-transferable days are part of each parent's pool and also subject to the age-4 cap), dubbeldagar (each counts as a day against the using parent's pool), and leap-year / month-arithmetic for `birth_date + 4 years`.
### 6.6 Per-year balance series (the analytics overview's year-by-year answer)
`Age4Projection.year_balances` (`ParentYearBalance`, `backend/app/services/analytics_service.py`)
gives the overview a vacation + parental-leave balance **per parent, per calendar year**, birth
year through the 4th-birthday year, instead of only the single age-4 total. The vacation half
(`AnalyticsService._vacation_year_series`) models Semesterlagen's carry-forward rule as a FIFO bank
of `(year_earned, count)` entries:
- Each year, planned use is drawn oldest-entry-first, then from that year's own entitlement (never
itself banked); at least `min_vacation_days_per_year` (the **floor**, default 20) must be taken
from the current year's own days, not the bank.
- At year end, only days earned *above* the floor (plus the surviving bank balance) carry forward,
and each carried-forward day **expires after 5 years** (`LeaveRules.vacation_carry_forward_years`)
— an entry still unconsumed in its final valid year drops out and is reported as `expiring` for
that year instead of carrying on.
- `saved_days_in` on a `vacation_days` row normally only **seeds** the bank for the series' first
year (no earlier history to derive it from). On any later year, a non-zero `saved_days_in` is
instead a Settings-UI **override**: it replaces the computed carry-in outright (resetting the bank
to one entry dated year − 1) rather than adding to it. A zero/absent value never overrides.
The frontend renders this as one row per year (`YearRows.tsx`, a `.disclosure` per year) on the
Dashboard, each showing carried-in/earned/used/carried-out per parent.
---
## 7. Frontend Type Safety
@@ -58,6 +58,7 @@ export function CalendarExceptions({
}: CalendarExceptionsProps): ReactNode {
const { t, locale } = useTranslation()
const endYear = endYearProp ?? startYear + years - 1
const currentYear = new Date().getFullYear()
const [pendingDate, setPendingDate] = useState<string | null>(null)
const [toggleError, setToggleError] = useState<string | null>(null)
@@ -179,8 +180,16 @@ export function CalendarExceptions({
{!isLoading &&
!error &&
Array.from(byYear.entries()).map(([year, items]) => (
<div key={year} className="red-days-list__year" data-testid={`red-days-year-${year}`}>
<h3 className="red-days-list__year-title">{year}</h3>
<details
key={year}
className="disclosure red-days-list__year"
data-testid={`red-days-year-${year}`}
open={year === currentYear}
>
<summary className="disclosure__summary red-days-list__year-summary">
<span className="red-days-list__year-title">{year}</span>
</summary>
<div className="disclosure__body red-days-list__year-body">
{items.length === 0 ? (
<p className="red-days-list__empty">{t('redDaysList.emptyYear')}</p>
) : (
@@ -224,6 +233,7 @@ export function CalendarExceptions({
</ul>
)}
</div>
</details>
))}
</div>
@@ -1,11 +1,8 @@
/* CalendarExceptions — merges the old RedDaysList (per-year red days +
* overrides) with DayOverridesInput's add-a-date form. Most row/list/form
* styling is already carried over unchanged from components.css under the
* same `.red-days-list__*` / `.day-overrides-input__*` class names (both
* components used them before being folded into this one); this file only
* adds what's new here — spacing between the two merged blocks and the two
* override-badge/effective-state variants the old components never needed
* together in the same list. */
* overrides, each year now a `.disclosure`) with DayOverridesInput's
* add-a-date form. All `.red-days-list__*` / `.day-overrides-input__*`
* rules live here (moved from components.css once this became the only
* consumer of those class names). */
.calendar-exceptions__help {
font-size: var(--fs-sm);
color: var(--c-text-muted);
@@ -39,3 +36,157 @@
color: var(--c-accent);
border-color: var(--c-accent-weak);
}
/* Per-year list — moved from components.css when the plain
* `.red-days-list__year` div became a `.disclosure` (only the current year
* open by default; see CalendarExceptions.tsx). */
.red-days-list__pagination {
display: flex;
align-items: center;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
}
.red-days-list__range {
font-weight: 600;
}
.red-days-list__year-summary {
font-size: var(--fs-lg);
}
.red-days-list__empty {
color: var(--c-text-muted);
font-size: var(--fs-sm);
}
.red-days-list__items {
list-style: none;
padding: 0;
margin: 0;
}
.red-days-list__item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-2) 0;
border-bottom: 1px solid var(--c-border);
font-size: var(--fs-sm);
}
.red-days-list__date {
font-weight: 600;
min-width: 160px;
}
.red-days-list__name {
color: var(--c-text);
}
/* `.red-days-list__effective` is a real `<button>` — clicking it toggles
* that one absolute date between its statutory value and the opposite
* override (§Task8). Sized by its own (bilingual) content, not a fixed
* width, since the Swedish labels run longer than the English ones. */
.red-days-list__effective {
margin-left: auto;
font-size: var(--fs-sm);
font-weight: 500;
color: var(--c-text-muted);
background-color: var(--c-bg);
border: 1px solid var(--c-border);
border-radius: var(--radius-full);
padding: 2px var(--sp-3);
cursor: pointer;
white-space: nowrap;
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
}
.red-days-list__effective:hover:not(:disabled) {
background-color: var(--c-accent-weak);
border-color: var(--c-accent);
color: var(--c-text);
}
.red-days-list__effective:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 2px;
}
.red-days-list__effective:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.red-days-list__effective--working {
background-color: var(--c-danger-weak);
color: var(--c-danger);
border-color: var(--c-danger-weak);
}
.red-days-list__loading,
.red-days-list__error,
.red-days-list__toggle-error {
color: var(--c-text-muted);
font-size: var(--fs-sm);
}
.red-days-list__toggle-error {
color: var(--c-danger);
margin: var(--sp-2) 0 0;
}
/* Add-a-date form — the rest of the old DayOverridesInput's own list/remove
* UI was dropped as redundant (see CalendarExceptions.tsx's doc comment);
* only this form survives, moved here from components.css. */
.day-overrides-input__form {
padding-top: var(--sp-4);
border-top: 1px solid var(--c-border);
}
.day-overrides-input__form h3 {
margin: 0 0 var(--sp-4) 0;
font-size: var(--fs-lg);
color: var(--c-text);
}
.day-overrides-input__form-group {
max-width: 400px;
margin-bottom: var(--sp-4);
}
.day-overrides-input__radio-group {
border: none;
padding: 0;
margin: 0;
}
.day-overrides-input__radio-group legend {
padding: 0;
margin: 0 0 var(--sp-2) 0;
}
.day-overrides-input__radio-label {
display: flex;
align-items: center;
gap: var(--sp-2);
margin-bottom: var(--sp-2);
cursor: pointer;
}
.day-overrides-input__radio-label:last-child {
margin-bottom: 0;
}
.day-overrides-input__radio-label input[type='radio'] {
width: auto;
margin: 0;
}
.day-overrides-input__char-count {
display: block;
margin-top: var(--sp-1);
font-size: var(--fs-sm);
color: var(--c-text-muted);
}
@@ -124,13 +124,14 @@ describe('ParentBalanceForm', () => {
/>,
)
expect(screen.getByText('= 240 av 240 dagar kvar')).toBeInTheDocument()
expect(screen.getByText(/= 240 av 240 dagar kvar/)).toBeInTheDocument()
expect(screen.getByText(/60 dubbeldagar kvar/)).toBeInTheDocument()
const sjukpenningInput = screen.getByLabelText(/Sjukpenningnivå kvar/)
await user.clear(sjukpenningInput)
await user.type(sjukpenningInput, '100')
expect(screen.getByText('= 145 av 240 dagar kvar')).toBeInTheDocument()
expect(screen.getByText(/= 145 av 240 dagar kvar/)).toBeInTheDocument()
})
it('shows an inline field error and does not call onSave when a field is left empty', async () => {
@@ -324,6 +324,8 @@ export function ParentBalanceForm({
<p className="parent-balance-form__summary">
{t('parentBalance.summary', { left: daysLeft, total: totalDays })}
<br />
{t('parentBalance.summaryDubbeldagar', { count: dubbeldagarLeft ?? 0 })}
</p>
{totalError && <p className="field__error">{totalError}</p>}
@@ -8,10 +8,8 @@
font-size: var(--fs-sm);
font-weight: 600;
color: var(--c-text);
line-height: 1.5;
margin: 0;
padding: var(--sp-2);
background-color: var(--c-bg);
border-radius: var(--radius-sm);
}
/* The overlay/content/header/close/body shell, form fields and footer
@@ -50,10 +50,6 @@
max-width: 300px;
width: 100%;
height: auto;
border: 1px solid var(--c-border);
border-radius: var(--radius);
background-color: var(--c-surface);
padding: var(--sp-2);
image-rendering: pixelated;
}
@@ -1,132 +0,0 @@
/**
* REFERENCE TEST — for the VacationDaysList component + useVacationDays hook.
*
* Pattern to copy for other component tests:
* - vi.mock('@/services/api') to isolate from the real HTTP layer.
* - Use @testing-library/react renderHook for hooks.
* - Use render + waitFor for async component tests.
* - Always cover: loading state, error state, empty state, and the happy path.
*/
import { render, renderHook, act, screen, waitFor } from '@testing-library/react'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import { VacationDaysList } from './VacationDaysList'
import { useVacationDays } from '@/hooks/useVacationDays'
import * as api from '@/services/api'
import type { VacationDays } from '@/types'
vi.mock('@/services/api')
const MOCK_WS = 'workspace-1'
const MOCK_VACATION_DAYS: VacationDays[] = [
{
id: 'vd-1',
personalDataUuid: 'pd-1',
workspaceId: MOCK_WS,
year: 2026,
totalDays: 25,
savedDaysIn: 5,
createdAt: '2026-01-01T00:00:00',
updatedAt: '2026-01-01T00:00:00',
},
{
id: 'vd-2',
personalDataUuid: 'pd-2',
workspaceId: MOCK_WS,
year: 2025,
totalDays: 25,
savedDaysIn: 0,
createdAt: '2025-01-01T00:00:00',
updatedAt: '2025-01-01T00:00:00',
},
]
beforeEach(() => {
vi.resetAllMocks()
})
// ---------------------------------------------------------------------------
// Component tests
// ---------------------------------------------------------------------------
describe('VacationDaysList', () => {
it('shows loading state while fetching', () => {
vi.mocked(api.getVacationDaysByWorkspace).mockReturnValue(new Promise(() => {}))
render(<VacationDaysList workspaceId={MOCK_WS} />)
expect(screen.getByRole('status')).toHaveTextContent('Laddar')
})
it('renders vacation days list on success', async () => {
vi.mocked(api.getVacationDaysByWorkspace).mockResolvedValue(MOCK_VACATION_DAYS)
render(<VacationDaysList workspaceId={MOCK_WS} />)
await waitFor(() => {
expect(screen.getByTestId('vacation-days-list')).toBeInTheDocument()
})
expect(screen.getByTestId('vacation-days-item-vd-1')).toHaveTextContent('2026')
expect(screen.getByTestId('vacation-days-item-vd-1')).toHaveTextContent('+5 sparade')
expect(screen.getByTestId('vacation-days-item-vd-2')).toHaveTextContent('2025')
})
it('shows error message on API failure', async () => {
vi.mocked(api.getVacationDaysByWorkspace).mockRejectedValue(new Error('Network error'))
render(<VacationDaysList workspaceId={MOCK_WS} />)
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Network error')
})
})
it('shows empty state when no vacation days exist', async () => {
vi.mocked(api.getVacationDaysByWorkspace).mockResolvedValue([])
render(<VacationDaysList workspaceId={MOCK_WS} />)
await waitFor(() => {
expect(screen.getByText(/inga semesterdagar/i)).toBeInTheDocument()
})
})
})
// ---------------------------------------------------------------------------
// Hook tests
// ---------------------------------------------------------------------------
describe('useVacationDays', () => {
it('fetches vacation days on mount', async () => {
vi.mocked(api.getVacationDaysByWorkspace).mockResolvedValue(MOCK_VACATION_DAYS)
const { result } = renderHook(() => useVacationDays(MOCK_WS))
expect(result.current.isLoading).toBe(true)
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.data).toEqual(MOCK_VACATION_DAYS)
expect(result.current.error).toBeNull()
})
it('sets error on fetch failure', async () => {
vi.mocked(api.getVacationDaysByWorkspace).mockRejectedValue(new Error('Timeout'))
const { result } = renderHook(() => useVacationDays(MOCK_WS))
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.error).toBe('Timeout')
expect(result.current.data).toEqual([])
})
it('upsert calls api and refreshes list', async () => {
const updated: VacationDays = { ...MOCK_VACATION_DAYS[0]!, totalDays: 30 }
vi.mocked(api.getVacationDaysByWorkspace)
.mockResolvedValueOnce(MOCK_VACATION_DAYS)
.mockResolvedValueOnce([updated])
vi.mocked(api.upsertVacationDays).mockResolvedValue(updated)
const { result } = renderHook(() => useVacationDays(MOCK_WS))
await waitFor(() => expect(result.current.isLoading).toBe(false))
await act(async () => {
await result.current.upsert({
personalDataUuid: 'pd-1',
workspaceId: MOCK_WS,
year: 2026,
totalDays: 30,
})
})
expect(api.upsertVacationDays).toHaveBeenCalledOnce()
expect(result.current.data[0]?.totalDays).toBe(30)
})
})
@@ -1,45 +0,0 @@
/**
* REFERENCE COMPONENT — renders the VacationDays list.
*
* Shows the established pattern:
* - Accept workspaceId as a prop (not from context directly — easier to test).
* - Delegate data fetching to the hook.
* - Handle loading / error / empty states explicitly.
*
* Copy this structure for other list components.
*/
import { useVacationDays } from '@/hooks/useVacationDays'
import { useTranslation } from '@/i18n'
import type { VacationDays } from '@/types'
interface Props {
workspaceId: string
}
export function VacationDaysList({ workspaceId }: Props) {
const { t } = useTranslation()
const { data, isLoading, error } = useVacationDays(workspaceId)
if (isLoading) return <p role="status">{t('vacationDaysList.loading')}</p>
if (error) return <p role="alert">{error}</p>
if (data.length === 0) return <p>{t('vacationDaysList.empty')}</p>
return (
<ul data-testid="vacation-days-list">
{data.map((vd) => (
<VacationDaysItem key={vd.id} vacationDays={vd} />
))}
</ul>
)
}
function VacationDaysItem({ vacationDays: vd }: { vacationDays: VacationDays }) {
const { t } = useTranslation()
return (
<li data-testid={`vacation-days-item-${vd.id}`}>
<strong>{vd.year}</strong>: {t('vacationDaysList.itemDays', { count: vd.totalDays })}
{vd.savedDaysIn > 0 && <span> {t('vacationDaysList.savedSuffix', { count: vd.savedDaysIn })}</span>}
</li>
)
}
+3 -4
View File
@@ -27,10 +27,9 @@ interface UseLatestBalanceResult {
* from either entry point.
*
* Imported directly from this file (not the `@/hooks` barrel) by both pages
* — same escape hatch `VacationDaysList` uses for `useVacationDays` — so
* their tests can drive it through a mocked `api.getParentalLeave` instead
* of mocking the hook itself, while the rest of `@/hooks` stays blanket-
* mocked.
* so their tests can drive it through a mocked `api.getParentalLeave`
* instead of mocking the hook itself, while the rest of `@/hooks` stays
* blanket-mocked.
*
* `personalDataUuid`/`childId` are null when no balance form is open — the
* hook then no-ops and reports no saved balance, rather than fetching.
-1
View File
@@ -4,7 +4,6 @@ export const auth = {
'auth.emailLabel': 'Email',
'auth.emailPlaceholder': 'Email',
'auth.genericError': 'Something went wrong',
'auth.loadingEllipsis': 'Loading…',
'auth.loginButton': 'Log in',
'auth.loginTitle': 'Log in',
'auth.nameLabel': 'Name',
-1
View File
@@ -6,7 +6,6 @@ export const balance = {
'balance.enterManually': 'Enter myself',
'balance.fetchFromFk': 'Fetch from Försäkringskassan',
'balance.loading': 'Loading balance…',
'balance.pickerPrompt': 'Whose balance is this?',
'balance.sourceDeduced': 'Derived {date}',
'balance.sourceManual': 'Entered manually {date}',
'balance.sourceScraped': 'From Försäkringskassan {date}',
-12
View File
@@ -1,15 +1,8 @@
export const calendar = {
'calendar.advanceMonthAria': '{month} - click to advance month',
'calendar.advanceYearAria': '{year} - click to advance year',
'calendar.selectMonthAria': 'Select month',
'calendar.selectYearAria': 'Select year',
'calendar.deleteError': 'Failed to delete the planned day',
'calendar.loading': 'Loading calendar...',
'calendar.nextMonthAria': 'Next month',
'calendar.overrideError': 'Failed to update the exception',
'calendar.overrideModeActiveLabel': 'Stop editing days',
'calendar.overrideModeHint': 'Click a day to mark it as a working day or a day off. Click a marked day again to reset it.',
'calendar.overrideModeLabel': 'Mark working/day off',
'calendar.partner': 'Partner',
'calendar.prevMonthAria': 'Previous month',
'calendar.saveError': 'Failed to save the planned days',
@@ -46,7 +39,6 @@ export const calendar = {
'plannedDayModal.pinnedNotice': 'This day is pinned by you — the planner will not change it automatically.',
'plannedDayModal.excludedNotice': 'You removed this day for {parent}.',
'plannedDayModal.restoreExcludedButton': 'Put the day back',
'plannedDayModal.dayStatusLabel': 'This day is:',
'plannedDayModal.rangeTitle': 'Plan {count} days',
'plannedDayModal.parentModeLabel': 'Who is this for?',
'plannedDayModal.wantsDubbeldagLabel': 'Allow double days (dubbeldagar)',
@@ -75,7 +67,6 @@ export const calendar = {
// §Task7 — the calendar becomes the plan: drag-select, period popover,
// preset rail, and the sync toast/undo.
'calendar.newPeriodName': 'New period',
'calendar.periodCreateError': 'Failed to create the period',
'calendar.syncError': 'Failed to sync the plan',
'calendar.syncingLabel': 'Syncing…',
@@ -118,7 +109,6 @@ export const calendar = {
'calendar.presetChipsLabel': 'Add leave',
'calendar.presetWeeksShort': '{count} wk',
'calendar.periodPopoverTitle': 'Edit period',
'calendar.periodAdHocTitle': 'Period',
'calendar.periodAdHocNotice': 'This period is not linked to a saved wish.',
'calendar.periodNameLabel': 'Name',
@@ -141,9 +131,7 @@ export const calendar = {
'calendar.periodLastYearNeedsSeriesHint': 'Choose "Every year" to change the last year.',
'calendar.periodSaveButton': 'Save period',
'calendar.periodSavingButton': 'Saving…',
'calendar.periodDeleteButton': 'Delete period',
'calendar.periodDeletingButton': 'Deleting…',
'calendar.periodDeleteConfirm': 'Are you sure you want to delete this period?',
'calendar.periodDeleteConfirmButton': 'Yes, delete',
'calendar.periodDeleteAdHocButton': 'Delete these days',
'calendar.periodCancelButton': 'Cancel',
-4
View File
@@ -1,9 +1,5 @@
export const common = {
'app.name': 'Föräldraledigplannerare',
'common.cancel': 'Cancel',
'common.close': 'Close',
'common.delete': 'Delete',
'common.deleting': 'Deleting…',
'common.loading': 'Loading',
'common.loadingEllipsis': 'Loading…',
'common.save': 'Save',
-1
View File
@@ -12,7 +12,6 @@ export const dashboard = {
// Still referenced by `Onboarding.tsx`'s balances step; the balance flow
// is moving to a `balance.*` namespace — drop this key once nothing else
// reads it.
'dashboard.fetchFromFk': 'Fetch from Försäkringskassan',
'dashboard.figureExpiring': 'Expiring at age 4',
'dashboard.figureLeft': 'Days left',
'dashboard.figureUsed': 'Used',
+1 -8
View File
@@ -21,7 +21,6 @@ export const settings = {
'parentBalance.dubbeldagarLeftLabel': 'Dubbeldagar left (0-60)',
'parentBalance.invalid': 'The total (sjukpenningnivå + lägstanivå) can\'t exceed {total} days.',
'parentBalance.lagstaLeftLabel': 'Lägstanivå (minimum-rate tier) left (0-90)',
'parentBalance.loadingBalance': 'Loading balance…',
'parentBalance.presetButton': 'No days taken yet',
'parentBalance.rangeError': 'Enter a number between {min} and {max}.',
'parentBalance.reservedExceedsError': 'Reserved days can\'t exceed the sjukpenningnivå + lägstanivå days left.',
@@ -32,6 +31,7 @@ export const settings = {
'parentBalance.savingButton': 'Saving…',
'parentBalance.sjukpenningLeftLabel': 'Sjukpenningnivå (income-based tier) left (0-390)',
'parentBalance.summary': '= {left} of {total} days left',
'parentBalance.summaryDubbeldagar': '{count} dubbeldagar left',
'parentBalance.title': '{name}\'s days left',
'parentBalance.titleSelf': 'Your days left',
'parentProfileInput.employed': 'Employed',
@@ -70,7 +70,6 @@ export const settings = {
'settings.languageEnglishOption': 'English',
'settings.languageLabel': 'Välj språk / Select language',
'settings.languageSwedishOption': 'Svenska',
'settings.loading': 'Loading settings...',
'settings.membersLoading': 'Loading members…',
'settings.noChildSelected': 'No child selected yet.',
'settings.partnerActionErrorNotFound': 'Could not find the partner. Reload the page and try again.',
@@ -112,15 +111,9 @@ export const settings = {
'settings.title': 'Settings',
'settings.vacationHelp': 'Enter vacation days per year. In the first year you are usually home more and have fewer days to spend on vacation, while later years have your full annual allotment. This controls how much time the planner has to work with.',
'settings.youBadge': '(You)',
'vacationDaysList.empty': 'No vacation days registered.',
'vacationDaysList.itemDays': '{count} days',
'vacationDaysList.loading': 'Loading vacation days…',
'vacationDaysList.savedSuffix': '(+{count} saved)',
'vacationDaysTable.assumedBadge': 'Assumed',
'vacationDaysTable.assumedPrompt': 'No value is recorded for this year — the statutory 25 days has been assumed. Confirm or edit below.',
'vacationDaysTable.empty': 'No years to show yet.',
'vacationDaysTable.saveButton': 'Save',
'vacationDaysTable.savedDaysHeader': 'Saved days carried in',
'vacationDaysTable.vacationDaysHeader': 'Vacation days',
'vacationDaysTable.yearHeader': 'Year',
} as const
-1
View File
@@ -4,7 +4,6 @@ export const auth = {
'auth.emailLabel': 'E-post',
'auth.emailPlaceholder': 'E-post',
'auth.genericError': 'Något gick fel',
'auth.loadingEllipsis': 'Laddar…',
'auth.loginButton': 'Logga in',
'auth.loginTitle': 'Logga in',
'auth.nameLabel': 'Namn',
-1
View File
@@ -15,7 +15,6 @@ export const balance = {
'balance.enterManually': 'Ange själv',
'balance.fetchFromFk': 'Hämta från Försäkringskassan',
'balance.loading': 'Laddar saldo…',
'balance.pickerPrompt': 'Vem gäller det?',
'balance.sourceDeduced': 'Härlett {date}',
'balance.sourceManual': 'Angivet manuellt {date}',
'balance.sourceScraped': 'Från Försäkringskassan {date}',
-12
View File
@@ -1,15 +1,8 @@
export const calendar = {
'calendar.advanceMonthAria': '{month} - klicka för att byta månad',
'calendar.advanceYearAria': '{year} - klicka för att byta år',
'calendar.selectMonthAria': 'Välj månad',
'calendar.selectYearAria': 'Välj år',
'calendar.deleteError': 'Det gick inte att ta bort den planerade dagen',
'calendar.loading': 'Laddar kalender...',
'calendar.nextMonthAria': 'Nästa månad',
'calendar.overrideError': 'Det gick inte att uppdatera undantaget',
'calendar.overrideModeActiveLabel': 'Avsluta redigering av dagar',
'calendar.overrideModeHint': 'Klicka på en dag för att markera den som arbetsdag eller ledig dag. Klicka igen på en markerad dag för att återställa den.',
'calendar.overrideModeLabel': 'Markera arbets-/ledig dag',
'calendar.partner': 'Partner',
'calendar.prevMonthAria': 'Föregående månad',
'calendar.saveError': 'Det gick inte att spara de planerade dagarna',
@@ -46,7 +39,6 @@ export const calendar = {
'plannedDayModal.pinnedNotice': 'Den här dagen är låst av dig — planeraren ändrar den inte automatiskt.',
'plannedDayModal.excludedNotice': 'Du har tagit bort den här dagen för {parent}.',
'plannedDayModal.restoreExcludedButton': 'Lägg tillbaka dagen',
'plannedDayModal.dayStatusLabel': 'Den här dagen är:',
'plannedDayModal.rangeTitle': 'Planera {count} dagar',
'plannedDayModal.parentModeLabel': 'Vem gäller det?',
'plannedDayModal.wantsDubbeldagLabel': 'Tillåt dubbeldagar',
@@ -75,7 +67,6 @@ export const calendar = {
// §Task7 — the calendar becomes the plan: drag-select, period popover,
// preset rail, and the sync toast/undo.
'calendar.newPeriodName': 'Ny period',
'calendar.periodCreateError': 'Det gick inte att skapa perioden',
'calendar.syncError': 'Det gick inte att synka planen',
'calendar.syncingLabel': 'Synkroniserar…',
@@ -118,7 +109,6 @@ export const calendar = {
'calendar.presetChipsLabel': 'Lägg till ledighet',
'calendar.presetWeeksShort': '{count} v',
'calendar.periodPopoverTitle': 'Redigera period',
'calendar.periodAdHocTitle': 'Period',
'calendar.periodAdHocNotice': 'Den här perioden är inte kopplad till en sparad önskan.',
'calendar.periodNameLabel': 'Namn',
@@ -142,9 +132,7 @@ export const calendar = {
'calendar.periodLastYearNeedsSeriesHint': 'Välj "Alla år" för att ändra sista året.',
'calendar.periodSaveButton': 'Spara period',
'calendar.periodSavingButton': 'Sparar…',
'calendar.periodDeleteButton': 'Ta bort period',
'calendar.periodDeletingButton': 'Tar bort…',
'calendar.periodDeleteConfirm': 'Är du säker på att du vill ta bort den här perioden?',
'calendar.periodDeleteConfirmButton': 'Ja, ta bort',
'calendar.periodDeleteAdHocButton': 'Ta bort dessa dagar',
'calendar.periodCancelButton': 'Avbryt',
-4
View File
@@ -1,9 +1,5 @@
export const common = {
'app.name': 'Föräldraledigplannerare',
'common.cancel': 'Avbryt',
'common.close': 'Stäng',
'common.delete': 'Ta bort',
'common.deleting': 'Tar bort…',
'common.loading': 'Laddar',
'common.loadingEllipsis': 'Laddar…',
'common.save': 'Spara',
-1
View File
@@ -12,7 +12,6 @@ export const dashboard = {
// Still referenced by `Onboarding.tsx`'s balances step; the balance flow
// is moving to a `balance.*` namespace — drop this key once nothing else
// reads it.
'dashboard.fetchFromFk': 'Hämta från Försäkringskassan',
'dashboard.figureExpiring': 'Förfaller vid 4 år',
'dashboard.figureLeft': 'Dagar kvar',
'dashboard.figureUsed': 'Använda',
+1 -8
View File
@@ -21,7 +21,6 @@ export const settings = {
'parentBalance.dubbeldagarLeftLabel': 'Dubbeldagar kvar (0–60)',
'parentBalance.invalid': 'Summan (sjukpenningnivå + lägstanivå) kan inte överstiga {total} dagar.',
'parentBalance.lagstaLeftLabel': 'Lägstanivå kvar (0–90)',
'parentBalance.loadingBalance': 'Laddar saldo…',
'parentBalance.presetButton': 'Inga dagar uttagna',
'parentBalance.rangeError': 'Ange ett tal mellan {min} och {max}.',
'parentBalance.reservedExceedsError': 'Reserverade dagar kan inte överstiga sjukpenningnivå + lägstanivå kvar.',
@@ -32,6 +31,7 @@ export const settings = {
'parentBalance.savingButton': 'Sparar…',
'parentBalance.sjukpenningLeftLabel': 'Sjukpenningnivå kvar (0–390)',
'parentBalance.summary': '= {left} av {total} dagar kvar',
'parentBalance.summaryDubbeldagar': '{count} dubbeldagar kvar',
'parentBalance.title': '{name}s dagar kvar',
'parentBalance.titleSelf': 'Dina dagar kvar',
'parentProfileInput.employed': 'Anställd',
@@ -70,7 +70,6 @@ export const settings = {
'settings.languageEnglishOption': 'English',
'settings.languageLabel': 'Välj språk / Select language',
'settings.languageSwedishOption': 'Svenska',
'settings.loading': 'Laddar inställningar...',
'settings.membersLoading': 'Laddar medlemmar…',
'settings.noChildSelected': 'Inget barn valt ännu.',
'settings.partnerActionErrorNotFound': 'Kunde inte hitta partnern. Ladda om sidan och försök igen.',
@@ -112,15 +111,9 @@ export const settings = {
'settings.title': 'Inställningar',
'settings.vacationHelp': 'Ange semesterdagar per år. Det första året är du oftast hemma mer och har färre dagar att lägga på semester, medan kommande år har din fulla årliga tilldelning. Detta styr hur mycket tid planeraren har att arbeta med.',
'settings.youBadge': '(Du)',
'vacationDaysList.empty': 'Inga semesterdagar registrerade.',
'vacationDaysList.itemDays': '{count} dagar',
'vacationDaysList.loading': 'Laddar semesterdagar…',
'vacationDaysList.savedSuffix': '(+{count} sparade)',
'vacationDaysTable.assumedBadge': 'Antaget',
'vacationDaysTable.assumedPrompt': 'Inget värde är registrerat för detta år — 25 dagar (lagstadgat) har antagits. Bekräfta eller ändra nedan.',
'vacationDaysTable.empty': 'Inga år att visa ännu.',
'vacationDaysTable.saveButton': 'Spara',
'vacationDaysTable.savedDaysHeader': 'Sparade dagar in',
'vacationDaysTable.vacationDaysHeader': 'Semesterdagar',
'vacationDaysTable.yearHeader': 'År',
} as const
+11 -7
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { useAuth } from '@/context/AuthContext'
import { useWorkspace } from '@/context/WorkspaceContext'
import { useTranslation } from '@/i18n'
import { LanguageToggle } from '@/components/layout/LanguageToggle'
import './auth.css'
export function Auth() {
@@ -48,12 +49,16 @@ export function Auth() {
return (
<main className="auth-page">
<div className="auth-page__card card">
<p className="eyebrow auth-page__brand">{t('app.name')}</p>
<div className="auth-page__tabs">
<div className="auth-page__lang">
<LanguageToggle />
</div>
<div className="auth-page__column">
<img className="auth-page__mark" src="/img/icon-64.png" alt="" width={32} height={32} />
<h1 className="auth-page__title">{isRegister ? t('auth.createAccountTitle') : t('auth.loginTitle')}</h1>
<div className="segmented auth-page__tabs" role="group">
<button
type="button"
className="btn btn--ghost auth-page__tab"
className="segmented__option auth-page__tab"
aria-pressed={!isRegister}
onClick={() => switchTab(false)}
data-testid="auth-tab-login"
@@ -62,7 +67,7 @@ export function Auth() {
</button>
<button
type="button"
className="btn btn--ghost auth-page__tab"
className="segmented__option auth-page__tab"
aria-pressed={isRegister}
onClick={() => switchTab(true)}
data-testid="auth-tab-register"
@@ -70,7 +75,6 @@ export function Auth() {
{t('auth.createAccountButton')}
</button>
</div>
<h1 className="auth-page__title">{isRegister ? t('auth.createAccountTitle') : t('auth.loginTitle')}</h1>
<form className="auth-page__form" onSubmit={(e) => void handleSubmit(e)}>
{isRegister && (
<div className="field">
@@ -115,7 +119,7 @@ export function Auth() {
/>
</div>
{error && (
<p role="alert" className="alert alert--error">
<p role="alert" className="auth-page__error">
{error}
</p>
)}
+34 -26
View File
@@ -1,8 +1,9 @@
/* Login/register — the app's front door, rendered outside <Layout> (no
* header yet, there's no session). Previously a bare, unstyled <main> that
* relied entirely on the global element resets; this gives it the same
* centered-card treatment as the onboarding wizard it leads into. */
* header yet, there's no session). A plain centred column, not a card on
* grey: the page background carries straight through, matching the rest
* of the almanac's whitespace-over-boxes language. */
.auth-page {
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
@@ -10,49 +11,56 @@
padding: var(--sp-5);
}
.auth-page__card {
width: 100%;
max-width: 380px;
.auth-page__lang {
position: absolute;
top: var(--sp-5);
right: var(--sp-5);
}
.auth-page__brand {
margin: 0 0 var(--sp-4) 0;
font-size: var(--fs-sm);
.auth-page__column {
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
align-items: center;
}
.auth-page__mark {
width: 32px;
height: 32px;
border-radius: 8px;
margin-bottom: var(--sp-4);
}
.auth-page__title {
font-size: var(--fs-xl);
text-align: center;
margin: 0 0 var(--sp-5) 0;
}
.auth-page__tabs {
display: flex;
gap: var(--sp-2);
margin-bottom: var(--sp-4);
width: 100%;
margin-bottom: var(--sp-5);
}
.auth-page__tab {
flex: 1;
justify-content: center;
}
/* Active tab — same "selected" formula as `.choice-card[aria-checked='true']`
* in primitives.css (accent-weak fill, accent text) so a pressed tab reads
* as "this is the current mode" without inventing another color pairing. */
.auth-page__tab[aria-pressed='true'] {
background-color: var(--c-accent-weak);
color: var(--c-accent);
}
.auth-page__title {
text-align: center;
margin-bottom: var(--sp-5);
}
.auth-page__form {
align-self: stretch;
display: flex;
flex-direction: column;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
}
.auth-page__form button[type='submit'] {
margin-top: var(--sp-2);
}
.auth-page__error {
margin: 0;
font-size: var(--fs-sm);
color: var(--c-danger);
}
-368
View File
@@ -635,134 +635,10 @@
min-height: 100px;
}
/* Input Grid */
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--sp-4);
}
.form-grid .form-group {
margin-bottom: 0;
}
.form-grid .form-group input,
.form-grid .form-group select {
max-width: none;
}
.settings-page > .alert {
margin-bottom: var(--sp-4);
}
.settings-page__help {
font-size: var(--fs-sm);
color: var(--c-text-muted);
margin: 0 0 var(--sp-4) 0;
max-width: 60ch;
}
.settings-page__field {
margin-bottom: var(--sp-4);
}
.settings-page__label {
font-size: var(--fs-base);
}
.settings-page__select {
max-width: 320px;
}
.settings-page__checkbox-label {
display: flex;
align-items: center;
gap: var(--sp-2);
cursor: pointer;
}
.settings-page__partner-row {
display: flex;
gap: var(--sp-3);
}
/* Box/border/focus chrome comes from `.field__control` in primitives.css —
* this only adds the flex sizing `.field__control`'s own rule doesn't know
* about (it sits in an inline row next to a button, not a stacked `.field`). */
.settings-page__partner-input {
flex: 1;
}
.settings-page__member-list {
list-style: none;
margin: 0 0 var(--sp-4) 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.settings-page__member-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-2) 0;
}
.settings-page__remove-confirm {
display: flex;
align-items: center;
gap: var(--sp-2);
color: var(--c-danger);
font-size: var(--fs-sm);
}
/* Vacation Days Table (multi-year) */
.vacation-days-table {
width: 100%;
border-collapse: collapse;
margin: 0 0 var(--sp-4) 0;
}
.vacation-days-table th {
padding: var(--sp-3) var(--sp-4);
background-color: var(--c-bg);
border-bottom: 2px solid var(--c-border);
font-weight: 600;
color: var(--c-text-muted);
text-align: left;
font-size: var(--fs-sm);
}
.vacation-days-table td {
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--c-border);
font-size: var(--fs-base);
}
.vacation-days-table tbody tr:last-child td {
border-bottom: none;
}
.vacation-days-table__year {
font-weight: 600;
}
.vacation-days-table__assumed-badge {
margin-left: var(--sp-2);
cursor: help;
}
.vacation-days-table__input {
width: 90px;
}
.vacation-days-table__empty {
color: var(--c-text-muted);
font-size: var(--fs-sm);
}
/* Fieldsets */
fieldset {
border: 1px solid var(--c-border);
@@ -812,247 +688,3 @@ legend {
color: var(--c-text-muted);
margin-top: var(--sp-2);
}
/* Day Overrides Input */
.day-overrides-input {
margin-bottom: var(--sp-5);
}
.day-overrides-input__empty {
padding: var(--sp-5);
background-color: var(--c-bg);
border-radius: var(--radius);
border: 1px solid var(--c-border);
color: var(--c-text-muted);
text-align: center;
margin-bottom: var(--sp-5);
}
.day-overrides-input__empty p {
margin: 0;
}
.day-overrides-input__list {
margin-bottom: var(--sp-5);
}
.day-overrides-input__items {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.day-overrides-input__item {
padding: var(--sp-4);
background-color: var(--c-bg);
border: 1px solid var(--c-border);
border-radius: var(--radius);
}
.day-overrides-input__item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--sp-3);
margin-bottom: var(--sp-3);
}
.day-overrides-input__item-date {
flex: 0 0 auto;
font-weight: 500;
}
.day-overrides-input__item-date strong {
color: var(--c-text);
}
.day-overrides-input__item-status {
flex: 1;
text-align: right;
}
.day-overrides-input__status {
display: inline-block;
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius);
font-size: var(--fs-sm);
font-weight: 500;
}
.day-overrides-input__status--working {
background-color: var(--c-warning-weak);
color: var(--c-warning);
}
.day-overrides-input__status--off {
background-color: var(--c-success-weak);
color: var(--c-success);
}
.day-overrides-input__item-note {
font-size: var(--fs-sm);
color: var(--c-text-muted);
margin-bottom: var(--sp-3);
}
/* Plain section, not a bordered card — matches the rest of Settings (see
* layout.css's "sections separate by whitespace and a hairline rule, not a
* stack of bordered/shadowed cards" comment). Only a top hairline separates
* it from the list of existing exceptions above. */
.day-overrides-input__form {
padding-top: var(--sp-4);
border-top: 1px solid var(--c-border);
}
.day-overrides-input__form h3 {
margin: 0 0 var(--sp-4) 0;
font-size: var(--fs-lg);
color: var(--c-text);
}
.day-overrides-input__form-group {
max-width: 400px;
margin-bottom: var(--sp-4);
}
.day-overrides-input__radio-group {
border: none;
padding: 0;
margin: 0;
}
.day-overrides-input__radio-group legend {
padding: 0;
margin: 0 0 var(--sp-2) 0;
}
.day-overrides-input__radio-label {
display: flex;
align-items: center;
gap: var(--sp-2);
margin-bottom: var(--sp-2);
cursor: pointer;
}
.day-overrides-input__radio-label:last-child {
margin-bottom: 0;
}
.day-overrides-input__radio-label input[type='radio'] {
width: auto;
margin: 0;
}
.day-overrides-input__char-count {
display: block;
margin-top: var(--sp-1);
font-size: var(--fs-sm);
color: var(--c-text-muted);
}
/* Red Days List (§3.8 — statutory red days + workspace overrides, per year) */
.red-days-list__pagination {
display: flex;
align-items: center;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
}
.red-days-list__range {
font-weight: 600;
}
.red-days-list__year {
margin-bottom: var(--sp-5);
}
.red-days-list__year-title {
font-size: var(--fs-lg);
margin: 0 0 var(--sp-2) 0;
}
.red-days-list__empty {
color: var(--c-text-muted);
font-size: var(--fs-sm);
}
.red-days-list__items {
list-style: none;
padding: 0;
margin: 0;
}
.red-days-list__item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-2) 0;
border-bottom: 1px solid var(--c-border);
font-size: var(--fs-sm);
}
.red-days-list__date {
font-weight: 600;
min-width: 160px;
}
.red-days-list__name {
color: var(--c-text);
}
/* `.red-days-list__effective` is a real `<button>` — clicking it toggles
* that one absolute date between its statutory value and the opposite
* override (§Task8). Sized by its own (bilingual) content, not a fixed
* width, since the Swedish labels run longer than the English ones. */
.red-days-list__effective {
margin-left: auto;
font-size: var(--fs-sm);
font-weight: 500;
color: var(--c-text-muted);
background-color: var(--c-bg);
border: 1px solid var(--c-border);
border-radius: var(--radius-full);
padding: 2px var(--sp-3);
cursor: pointer;
white-space: nowrap;
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
}
.red-days-list__effective:hover:not(:disabled) {
background-color: var(--c-accent-weak);
border-color: var(--c-accent);
color: var(--c-text);
}
.red-days-list__effective:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 2px;
}
.red-days-list__effective:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.red-days-list__effective--working {
background-color: var(--c-danger-weak);
color: var(--c-danger);
border-color: var(--c-danger-weak);
}
.red-days-list__loading,
.red-days-list__error,
.red-days-list__toggle-error {
color: var(--c-text-muted);
font-size: var(--fs-sm);
}
.red-days-list__toggle-error {
color: var(--c-danger);
margin: var(--sp-2) 0 0;
}
-27
View File
@@ -84,25 +84,6 @@
max-width: none;
}
.form-grid {
grid-template-columns: 1fr;
}
.form-grid .form-group input,
.form-grid .form-group select {
max-width: none;
}
/* Tables */
.vacation-days-table {
font-size: var(--fs-sm);
}
.vacation-days-table th,
.vacation-days-table td {
padding: var(--sp-2) var(--sp-3);
}
/* Dashboard — the three key figures tighten; a year row keeps year +
* track on the first line and wraps the per-parent figures under the
* track, each parent on its own line. */
@@ -143,10 +124,6 @@
width: 100%;
}
.vacation-days-table__input {
width: 70px;
}
/* Modals — the overlay already gutters the content with its own padding
* and `.modal__content`'s `width: min(100%, var(--modal-w))` already caps
* it to the viewport, so only the footer's button stacking needs a
@@ -228,10 +205,6 @@
/* Tablet - Medium screens (641px to 1024px) */
@media (min-width: 641px) and (max-width: 1024px) {
.form-grid {
grid-template-columns: repeat(2, 1fr);
}
.layout__main {
padding: var(--sp-6) var(--sp-5);
}
+1 -13
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { getColorForType, getInkForType, getLabelForType, getTypeKey, getTypeModifier } from './leaveTypes'
import { getColorForType, getInkForType, getLabelForType, getTypeKey } from './leaveTypes'
import { translate } from '@/i18n'
describe('getColorForType', () => {
@@ -44,18 +44,6 @@ describe('getTypeKey', () => {
})
})
describe('getTypeModifier', () => {
it('returns a type-* modifier suffix for each known type', () => {
expect(getTypeModifier('vacation')).toBe('type-vacation')
expect(getTypeModifier('sjukpenning')).toBe('type-sjukpenning')
expect(getTypeModifier('lagsta')).toBe('type-lagsta')
})
it('returns type-unknown for an unknown type', () => {
expect(getTypeModifier('something-else')).toBe('type-unknown')
})
})
describe('getLabelForType', () => {
const t = (key: Parameters<typeof translate>[1]) => translate('sv', key)
-9
View File
@@ -36,15 +36,6 @@ export function getTypeKey(type: string): LeaveTypeKey {
return isKnownType(type) ? type : 'unknown'
}
/**
* The older `type-*` prefixed modifier, still used by the dashboard's year
* timeline (`year-timeline__band--type-lagsta`); the calendar has moved to
* `getTypeKey`.
*/
export function getTypeModifier(type: string): string {
return `type-${getTypeKey(type)}`
}
/**
* Localized label for a leave-day type. Takes `t` rather than importing
* `useTranslation` itself — this is a plain utility function (not a