Redesign the setup flow and consolidate the CSS into one primitives layer
Onboarding asks explicitly for one or two parents and shows one card per parent for balances; the balance form is per parent with inline validation; the partner can be filled from the FK deduction or added from it. Settings and Dashboard reuse the same form. CSS: bare buttons carry no colour any more; buttons, cards, choice cards, fields, alerts, badges, eyebrow and modal chrome are defined once in primitives.css. Media queries live only in responsive.css. A style guard test blocks hex, !important and stray media queries. Bilingual fixes: calendar selects size by content, header no longer wraps. Vitest is capped at four workers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,16 @@ describe('Hero', () => {
|
||||
expect(screen.getByTestId('dashboard-hero')).toHaveAttribute('role', 'alert')
|
||||
})
|
||||
|
||||
it('uses a calm reassurance framing when a plan exists and nothing is at risk', () => {
|
||||
render(
|
||||
<Hero hasPlan={true} daysLeft={200} daysAtRisk={0} childName="Nova" fourthBirthday="2029-06-01" />,
|
||||
)
|
||||
const hero = screen.getByTestId('dashboard-hero')
|
||||
expect(screen.getByTestId('dashboard-hero-message')).not.toHaveTextContent(/riskerar att försvinna/)
|
||||
expect(hero).not.toHaveClass('dashboard-hero--warning')
|
||||
expect(hero).not.toHaveAttribute('role', 'alert')
|
||||
})
|
||||
|
||||
it('renders the same daysAtRisk figure in both framings without recomputing it', () => {
|
||||
const { rerender } = render(
|
||||
<Hero hasPlan={false} daysLeft={480} daysAtRisk={104} childName="Nova" fourthBirthday="2029-06-01" />,
|
||||
|
||||
@@ -16,32 +16,36 @@ export interface HeroProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* The headline: same arithmetic (`daysAtRisk` = `combinedExpiringAtB4`), two
|
||||
* framings depending on whether anything has been planned yet — a forward-
|
||||
* looking nudge with nothing planned (the entire entitlement is technically
|
||||
* "at risk" on day one, which reads as an accusation before the user has
|
||||
* done anything), a plain warning once a plan exists.
|
||||
* The headline: same arithmetic (`daysAtRisk` = `combinedExpiringAtB4`),
|
||||
* three framings — a forward-looking nudge with nothing planned yet (the
|
||||
* entire entitlement is technically "at risk" on day one, which reads as an
|
||||
* accusation before the user has done anything), a calm confirmation once a
|
||||
* plan exists and nothing is actually at risk, and a plain warning once a
|
||||
* plan exists and some days are at risk.
|
||||
*/
|
||||
export function Hero({ hasPlan, daysLeft, daysAtRisk, childName, fourthBirthday }: HeroProps): ReactNode {
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
const noneAtRisk = daysAtRisk === 0
|
||||
const date = formatDateForDisplay(fourthBirthday, locale)
|
||||
|
||||
const message = hasPlan
|
||||
? t('dashboard.hero.hasPlanMessage', {
|
||||
atRisk: daysAtRisk,
|
||||
childName,
|
||||
date: formatDateForDisplay(fourthBirthday, locale),
|
||||
})
|
||||
? noneAtRisk
|
||||
? t('dashboard.hero.noneAtRiskMessage', { childName, date })
|
||||
: t('dashboard.hero.hasPlanMessage', { atRisk: daysAtRisk, childName, date })
|
||||
: t('dashboard.hero.noPlanMessage', {
|
||||
days: daysLeft,
|
||||
atRisk: daysAtRisk,
|
||||
childName,
|
||||
})
|
||||
|
||||
const modifier = hasPlan ? (noneAtRisk ? 'dashboard-hero--reassure' : 'dashboard-hero--warning') : 'dashboard-hero--motivate'
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="dashboard-hero"
|
||||
className={`dashboard-hero ${hasPlan ? 'dashboard-hero--warning' : 'dashboard-hero--motivate'}`}
|
||||
role={hasPlan ? 'alert' : undefined}
|
||||
className={`dashboard-hero ${modifier}`}
|
||||
role={hasPlan && !noneAtRisk ? 'alert' : undefined}
|
||||
>
|
||||
<p className="dashboard-hero__message" data-testid="dashboard-hero-message">
|
||||
{message}
|
||||
|
||||
@@ -42,6 +42,18 @@ describe('OverviewMetrics', () => {
|
||||
expect(screen.getByTestId('custom-test-id')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a summary metric as a full-width footer row', () => {
|
||||
const metrics = [
|
||||
{ label: 'A', value: '1', testId: 'metric-a' },
|
||||
{ label: 'B', value: '2', testId: 'metric-b' },
|
||||
{ label: 'Total', value: '3', testId: 'metric-total', isSummary: true },
|
||||
]
|
||||
render(<OverviewMetrics metrics={metrics} />)
|
||||
|
||||
expect(screen.getByTestId('metric-total')).toHaveClass('overview-metrics__item--summary')
|
||||
expect(screen.getByTestId('metric-a')).not.toHaveClass('overview-metrics__item--summary')
|
||||
})
|
||||
|
||||
it('has proper accessibility structure', () => {
|
||||
const metrics = [{ label: 'Metric', value: '50' }]
|
||||
render(<OverviewMetrics metrics={metrics} title="Metrics Section" />)
|
||||
|
||||
@@ -5,6 +5,10 @@ interface Metric {
|
||||
label: string
|
||||
value: string | number
|
||||
testId?: string
|
||||
// A combined/"all parents" total that summarizes the other metrics above
|
||||
// it — rendered as a full-width footer row instead of just another grid
|
||||
// cell, so an odd item count never leaves a dangling gap in the last row.
|
||||
isSummary?: boolean
|
||||
}
|
||||
|
||||
interface OverviewMetricsProps {
|
||||
@@ -25,7 +29,11 @@ export function OverviewMetrics({ metrics, title }: OverviewMetricsProps): React
|
||||
role="listitem"
|
||||
data-testid={metric.testId || `metric-${index}`}
|
||||
data-label={metric.label}
|
||||
className="overview-metrics__item"
|
||||
className={
|
||||
metric.isSummary
|
||||
? 'overview-metrics__item overview-metrics__item--summary'
|
||||
: 'overview-metrics__item'
|
||||
}
|
||||
>
|
||||
<span data-testid={`metric-label-${index}`} className="overview-metrics__label">{metric.label}:</span>
|
||||
<span data-testid={`metric-value-${index}`} className="overview-metrics__value">{metric.value}</span>
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('PlanFold', () => {
|
||||
vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] }))
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(screen.getByTestId('plan-fold-no-wishes')).toHaveTextContent(
|
||||
'Lägg till önskemål under Planering för att se ett förslag här.',
|
||||
'Lägg till önskemål under Kalender för att se ett förslag här.',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ export function PlanFold({ workspaceId, members }: PlanFoldProps): ReactNode {
|
||||
)}
|
||||
|
||||
{proposalError && (
|
||||
<div className="plan-fold__error" role="alert" data-testid="plan-fold-proposal-error">
|
||||
<div className="alert alert--error" role="alert" data-testid="plan-fold-proposal-error">
|
||||
{proposalError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('YearTimeline', () => {
|
||||
expect(screen.getAllByTestId(/^year-timeline-band-2026-/)).toHaveLength(1)
|
||||
expect(screen.getByTestId('year-timeline-band-2026-0')).toHaveAttribute(
|
||||
'title',
|
||||
'Semester, 2026-06-15 – 2026-06-17',
|
||||
'Semester, 15 juni 2026 – 17 juni 2026 (3 dagar). Klicka för att öppna denna månad i kalendern.',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -98,4 +98,36 @@ describe('YearTimeline', () => {
|
||||
await user.click(screen.getByTestId('year-timeline-band-2026-0'))
|
||||
expect(onBandClick).toHaveBeenCalledWith('2026-06-15')
|
||||
})
|
||||
|
||||
it('renders an explanatory caption below the title', () => {
|
||||
render(
|
||||
<YearTimeline plannedDays={[]} startDate="2025-06-01" endDate="2029-06-01" onBandClick={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByText(/Varje rad är ett år/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a colour legend with one entry per day type', () => {
|
||||
render(
|
||||
<YearTimeline plannedDays={[]} startDate="2025-06-01" endDate="2029-06-01" onBandClick={vi.fn()} />,
|
||||
)
|
||||
const legend = screen.getByTestId('year-timeline-legend')
|
||||
expect(legend).toHaveTextContent('Sjukpenningnivå')
|
||||
expect(legend).toHaveTextContent('Lägstanivå')
|
||||
expect(legend).toHaveTextContent('Semester')
|
||||
})
|
||||
|
||||
it('includes the day count in a band\'s label', () => {
|
||||
render(
|
||||
<YearTimeline
|
||||
plannedDays={[makePlannedLeaveDay({ id: '1', date: '2026-06-15', dayType: 'vacation' })]}
|
||||
startDate="2025-06-01"
|
||||
endDate="2029-06-01"
|
||||
onBandClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('year-timeline-band-2026-0')).toHaveAttribute(
|
||||
'title',
|
||||
'Semester, 15 juni 2026 – 15 juni 2026 (1 dag). Klicka för att öppna denna månad i kalendern.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ReactNode } from 'react'
|
||||
import type { PlannedLeaveDay } from '@/types'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { getColorForType, getLabelForType } from '@/utils/leaveTypes'
|
||||
import { formatDateForDisplay } from '@/utils/dates'
|
||||
|
||||
export interface YearTimelineProps {
|
||||
plannedDays: PlannedLeaveDay[]
|
||||
@@ -17,6 +18,9 @@ interface Band {
|
||||
startDate: string
|
||||
endDate: string
|
||||
dominantType: PlannedLeaveDay['dayType']
|
||||
/** Number of planned days actually in this band (not the calendar span —
|
||||
* a bridged weekend/röd dag inside the band isn't a planned day). */
|
||||
dayCount: number
|
||||
}
|
||||
|
||||
// A run of planned days separates into a new band once the gap between
|
||||
@@ -79,6 +83,7 @@ function buildBands(plannedDays: PlannedLeaveDay[], startDate: string, endDate:
|
||||
startDate: current[0]!.date,
|
||||
endDate: current[current.length - 1]!.date,
|
||||
dominantType: dominantType(current),
|
||||
dayCount: current.length,
|
||||
})
|
||||
current = []
|
||||
}
|
||||
@@ -110,7 +115,7 @@ function buildBands(plannedDays: PlannedLeaveDay[], startDate: string, endDate:
|
||||
* per-parent day bars above it.
|
||||
*/
|
||||
export function YearTimeline({ plannedDays, startDate, endDate, onBandClick }: YearTimelineProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const { t, locale } = useTranslation()
|
||||
const start = toUtcDate(startDate)
|
||||
const end = toUtcDate(endDate)
|
||||
const startYear = start.getUTCFullYear()
|
||||
@@ -124,6 +129,19 @@ export function YearTimeline({ plannedDays, startDate, endDate, onBandClick }: Y
|
||||
return (
|
||||
<div data-testid="year-timeline" className="year-timeline">
|
||||
<h3 className="year-timeline__title">{t('yearTimeline.title')}</h3>
|
||||
<p className="year-timeline__caption">{t('yearTimeline.caption')}</p>
|
||||
<ul className="year-timeline__legend" data-testid="year-timeline-legend">
|
||||
{TYPE_PRIORITY.map((type) => (
|
||||
<li key={type} className="year-timeline__legend-item">
|
||||
<span
|
||||
className="year-timeline__legend-swatch"
|
||||
style={{ backgroundColor: getColorForType(type) }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{getLabelForType(type, t)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{years.map((year) => {
|
||||
const yearLength = daysInYear(year)
|
||||
const bandsForYear = bands.filter((b) => toUtcDate(b.startDate).getUTCFullYear() === year)
|
||||
@@ -134,11 +152,15 @@ export function YearTimeline({ plannedDays, startDate, endDate, onBandClick }: Y
|
||||
{bandsForYear.map((band, idx) => {
|
||||
const left = ((dayOfYearUtc(toUtcDate(band.startDate)) - 1) / yearLength) * 100
|
||||
const width = ((dayOfYearUtc(toUtcDate(band.endDate)) - dayOfYearUtc(toUtcDate(band.startDate)) + 1) / yearLength) * 100
|
||||
const label = t('yearTimeline.bandLabel', {
|
||||
dayType: getLabelForType(band.dominantType, t),
|
||||
startDate: band.startDate,
|
||||
endDate: band.endDate,
|
||||
})
|
||||
const label = t(
|
||||
band.dayCount === 1 ? 'yearTimeline.bandLabelOneDay' : 'yearTimeline.bandLabel',
|
||||
{
|
||||
dayType: getLabelForType(band.dominantType, t),
|
||||
startDate: formatDateForDisplay(band.startDate, locale),
|
||||
endDate: formatDateForDisplay(band.endDate, locale),
|
||||
dayCount: band.dayCount,
|
||||
},
|
||||
)
|
||||
return (
|
||||
<button
|
||||
key={`${year}-${idx}`}
|
||||
|
||||
Reference in New Issue
Block a user