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
@@ -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,51 +180,60 @@ 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>
{items.length === 0 ? (
<p className="red-days-list__empty">{t('redDaysList.emptyYear')}</p>
) : (
<ul className="red-days-list__items">
{items.map((day) => {
const override = overrideByDate.get(day.date)
return (
<li key={day.date} className="red-days-list__item" data-testid={`red-days-item-${day.date}`}>
<span className="red-days-list__date">{formatDateForDisplay(day.date, locale)}</span>
{day.dayType === 'red_day' && day.name && (
<span className="red-days-list__name">{formatHolidayName(day.name)}</span>
)}
{day.isOverride && (
<span
className={`badge badge--${day.overrideIsWorkingDay ? 'danger' : 'accent'} red-days-list__override-badge red-days-list__override-badge--${day.overrideIsWorkingDay ? 'working' : 'free'}`}
data-testid={`red-days-override-badge-${day.date}`}
<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>
) : (
<ul className="red-days-list__items">
{items.map((day) => {
const override = overrideByDate.get(day.date)
return (
<li key={day.date} className="red-days-list__item" data-testid={`red-days-item-${day.date}`}>
<span className="red-days-list__date">{formatDateForDisplay(day.date, locale)}</span>
{day.dayType === 'red_day' && day.name && (
<span className="red-days-list__name">{formatHolidayName(day.name)}</span>
)}
{day.isOverride && (
<span
className={`badge badge--${day.overrideIsWorkingDay ? 'danger' : 'accent'} red-days-list__override-badge red-days-list__override-badge--${day.overrideIsWorkingDay ? 'working' : 'free'}`}
data-testid={`red-days-override-badge-${day.date}`}
>
{day.overrideIsWorkingDay ? t('redDaysList.overrideWorking') : t('redDaysList.overrideFree')}
</span>
)}
{override?.note && (
<span className="red-days-list__note">{t('dayOverridesInput.noteLabel', { note: override.note })}</span>
)}
<button
type="button"
className={`red-days-list__effective red-days-list__effective--${day.isFree ? 'free' : 'working'}`}
onClick={() => handleToggle(day)}
disabled={pendingDate === day.date}
aria-label={
day.isFree
? t('redDaysList.toggleToWorkingAria', { date: formatDateForDisplay(day.date) })
: t('redDaysList.toggleToFreeAria', { date: formatDateForDisplay(day.date) })
}
data-testid={`red-days-toggle-${day.date}`}
>
{day.overrideIsWorkingDay ? t('redDaysList.overrideWorking') : t('redDaysList.overrideFree')}
</span>
)}
{override?.note && (
<span className="red-days-list__note">{t('dayOverridesInput.noteLabel', { note: override.note })}</span>
)}
<button
type="button"
className={`red-days-list__effective red-days-list__effective--${day.isFree ? 'free' : 'working'}`}
onClick={() => handleToggle(day)}
disabled={pendingDate === day.date}
aria-label={
day.isFree
? t('redDaysList.toggleToWorkingAria', { date: formatDateForDisplay(day.date) })
: t('redDaysList.toggleToFreeAria', { date: formatDateForDisplay(day.date) })
}
data-testid={`red-days-toggle-${day.date}`}
>
{day.isFree ? t('redDaysList.effectiveFree') : t('redDaysList.effectiveWorking')}
</button>
</li>
)
})}
</ul>
)}
</div>
{day.isFree ? t('redDaysList.effectiveFree') : t('redDaysList.effectiveWorking')}
</button>
</li>
)
})}
</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>
)
}