Phase 2: Build 9 presentational components with full test coverage

New components (all with .tsx + .test.tsx, 90%+ coverage):
- CalendarDay: day cell with event badges
- CalendarEvent: colored event chip with dismiss option
- DaysBreakdown: table of days by type
- ProgressChart: progress bar toward age-4 deadline
- TipCard: optimization tip card with severity badges
- VacationDaysInput: number input with min/max validation
- ParentProfileInput: dual selects for salary type & employment status
- PlanningGoalSelector: radio buttons for planning goal
- BreakPeriodsInput: list manager with inline add/edit form

All components follow existing patterns from Header/Footer:
- Typed props interfaces (exported)
- Semantic HTML with data-testid & ARIA attributes
- No business logic, purely presentational
- Comprehensive test coverage including edge cases

npm test: 22 files, 167 tests passing
npx tsc --noEmit: clean

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 17:50:23 +02:00
parent faebcced8a
commit 425e29ea21
19 changed files with 2048 additions and 0 deletions
@@ -0,0 +1,815 @@
"""Tests for the tips-engine rule implementations.
Rules are tested by constructing TipsContext directly — no database required.
Each rule gets at minimum a fire case and a no-fire case.
"""
from datetime import date, datetime, timedelta
from uuid import UUID, uuid4
import pytest
from dateutil.relativedelta import relativedelta
from app.entities.break_period import BreakPeriod
from app.entities.child import Child
from app.entities.parental_leave import ParentalLeaveFetch, TierData
from app.entities.personal_data import PersonalData
from app.entities.planned_leave import PlannedLeaveDay
from app.services.tips_service import (
TipsContext,
_rule_balance_between_parents,
_rule_lagsta_niva_coverage,
_rule_long_break_parent_swap,
_rule_preserve_for_age_4,
_rule_reserve_for_preschool_planning_days,
_rule_stretch_time_at_home,
_rule_use_dubbeldagar_strategically,
_rule_wrap_red_days_with_vacation,
_rule_wrap_weekend_with_vacation,
)
from app.utils.tip_predicates import red_day_clusters_in_range
# ---------------------------------------------------------------------------
# Factories
# ---------------------------------------------------------------------------
def _make_child(birth_date: date, preschool_start_date: date | None = None) -> Child:
ws = uuid4()
return Child(
id=uuid4(),
workspace_id=ws,
name="TestChild",
birth_date=birth_date,
preschool_start_date=preschool_start_date,
)
def _make_parent(
workspace_id: UUID | None = None,
name: str = "Parent",
salary_type: str = "monthly",
employment_status: str = "employed",
) -> PersonalData:
return PersonalData(
uuid=uuid4(),
workspace_id=workspace_id or uuid4(),
name=name,
salary_type=salary_type,
employment_status=employment_status,
)
def _make_leave(
parent: PersonalData,
child_id: UUID,
total: int = 240,
used: int = 100,
sjukpenning_left: int = 200,
lagsta_left: int = 60,
) -> ParentalLeaveFetch:
now = datetime.now()
tier = TierData(
sjukpenning_total=390,
sjukpenning_used=390 - sjukpenning_left,
sjukpenning_left=sjukpenning_left,
lagsta_niva_total=90,
lagsta_niva_used=90 - lagsta_left,
lagsta_niva_left=lagsta_left,
)
return ParentalLeaveFetch(
id=uuid4(),
personal_data_uuid=parent.uuid,
workspace_id=parent.workspace_id,
child_id=child_id,
source="manual",
fetched_at=now,
total_days=total,
days_used=used,
days_left=total - used,
reserved_days_total=90,
reserved_days_left=90,
transferable_days_left=50,
dubbeldagar_left=60,
tier_data=tier,
created_at=now,
updated_at=now,
)
def _make_fp_day(
parent: PersonalData,
child_id: UUID,
workspace_id: UUID,
day: date,
day_type: str = "sjukpenning",
is_dubbeldag: bool = False,
) -> PlannedLeaveDay:
return PlannedLeaveDay(
id=uuid4(),
workspace_id=workspace_id,
child_id=child_id,
parent_personal_data_uuid=parent.uuid,
date=day,
day_type=day_type,
is_dubbeldag=is_dubbeldag,
)
def _make_break(
workspace_id: UUID,
start: date,
end: date,
name: str = "Summer",
target_weeks: int = 2,
) -> BreakPeriod:
return BreakPeriod(
id=uuid4(),
workspace_id=workspace_id,
name=name,
start_date=start,
end_date=end,
target_weeks=target_weeks,
)
def _make_ctx(
workspace_id: UUID | None = None,
planning_goal: str = "balanced",
children: list | None = None,
parents: list | None = None,
leave_by_parent: dict | None = None,
planned_days: list | None = None,
vacation_by_parent: dict | None = None,
break_periods: list | None = None,
) -> TipsContext:
ws = workspace_id or uuid4()
return TipsContext(
workspace_id=ws,
workspace_planning_goal=planning_goal,
children=children or [],
parents=parents or [],
leave_by_parent=leave_by_parent or {},
planned_days=planned_days or [],
vacation_by_parent=vacation_by_parent or {},
break_periods=break_periods or [],
)
def _next_weekday(target_weekday: int, offset_days: int = 1) -> date:
"""Find the next occurrence of weekday (0=Mon, 4=Fri) from today+offset_days."""
d = date.today() + timedelta(days=offset_days)
while d.weekday() != target_weekday:
d += timedelta(days=1)
return d
# ---------------------------------------------------------------------------
# _rule_balance_between_parents
# ---------------------------------------------------------------------------
class TestBalanceBetweenParents:
async def test_fires_when_gap_exceeds_30(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p1 = _make_parent(workspace_id=ws, name="Alice")
p2 = _make_parent(workspace_id=ws, name="Bob")
leave1 = _make_leave(p1, child.id, used=120)
leave2 = _make_leave(p2, child.id, used=60) # gap = 60 > 30
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p1, p2],
leave_by_parent={p1.uuid: leave1, p2.uuid: leave2},
)
tip = await _rule_balance_between_parents(ctx)
assert tip is not None
assert tip.id == "balance_between_parents"
assert tip.data["gap_days"] == 60
assert tip.data["leading_parent"] == "Alice"
assert tip.data["lagging_parent"] == "Bob"
async def test_no_fire_when_gap_within_threshold(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p1 = _make_parent(workspace_id=ws, name="Alice")
p2 = _make_parent(workspace_id=ws, name="Bob")
leave1 = _make_leave(p1, child.id, used=120)
leave2 = _make_leave(p2, child.id, used=105) # gap = 15 ≤ 30
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p1, p2],
leave_by_parent={p1.uuid: leave1, p2.uuid: leave2},
)
tip = await _rule_balance_between_parents(ctx)
assert tip is None
async def test_no_fire_with_single_parent(self):
ws = uuid4()
p1 = _make_parent(workspace_id=ws)
child = _make_child(date.today() - relativedelta(years=1))
leave = _make_leave(p1, child.id, used=120)
ctx = _make_ctx(
workspace_id=ws,
parents=[p1],
leave_by_parent={p1.uuid: leave},
)
tip = await _rule_balance_between_parents(ctx)
assert tip is None
async def test_no_fire_when_leave_data_missing(self):
ws = uuid4()
p1 = _make_parent(workspace_id=ws)
p2 = _make_parent(workspace_id=ws)
# Only one parent has leave data
child = _make_child(date.today() - relativedelta(years=1))
leave1 = _make_leave(p1, child.id, used=200)
ctx = _make_ctx(
workspace_id=ws,
parents=[p1, p2],
leave_by_parent={p1.uuid: leave1, p2.uuid: None},
)
tip = await _rule_balance_between_parents(ctx)
assert tip is None
# ---------------------------------------------------------------------------
# _rule_preserve_for_age_4
# ---------------------------------------------------------------------------
class TestPreserveForAge4:
async def test_fires_when_days_will_expire(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws)
# total=240, used=20, no planned → projected_left=220, expiring=220-96=124
leave = _make_leave(p, child.id, total=240, used=20)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p],
leave_by_parent={p.uuid: leave},
)
tip = await _rule_preserve_for_age_4(ctx)
assert tip is not None
assert tip.id == "preserve_for_age_4"
assert tip.severity == "warning"
assert tip.data["expiring_days"] == 124
async def test_no_fire_when_projected_days_within_savable_limit(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws)
# total=240, used=150, no planned → projected_left=90 ≤ 96 → expiring=0
leave = _make_leave(p, child.id, total=240, used=150)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p],
leave_by_parent={p.uuid: leave},
)
tip = await _rule_preserve_for_age_4(ctx)
assert tip is None
async def test_no_fire_when_no_children(self):
ws = uuid4()
p = _make_parent(workspace_id=ws)
ctx = _make_ctx(workspace_id=ws, parents=[p])
tip = await _rule_preserve_for_age_4(ctx)
assert tip is None
async def test_planned_days_counted_before_fourth_birthday(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws)
# total=240, used=20, planned 50 before age 4 → projected_left=170, expiring=74
leave = _make_leave(p, child.id, total=240, used=20)
fourth_bday = child.birth_date + relativedelta(years=4)
planned = [
_make_fp_day(p, child.id, ws, fourth_bday - timedelta(days=i + 1))
for i in range(50)
]
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p],
leave_by_parent={p.uuid: leave},
planned_days=planned,
)
tip = await _rule_preserve_for_age_4(ctx)
assert tip is not None
assert tip.data["expiring_days"] == 74
# ---------------------------------------------------------------------------
# _rule_use_dubbeldagar_strategically
# ---------------------------------------------------------------------------
class TestUseDubbeldagarStrategically:
async def test_fires_when_same_day_not_marked(self):
ws = uuid4()
child = _make_child(date.today() - timedelta(days=30))
p1 = _make_parent(workspace_id=ws)
p2 = _make_parent(workspace_id=ws)
shared_date = date.today() + timedelta(days=10)
d1 = _make_fp_day(p1, child.id, ws, shared_date, is_dubbeldag=False)
d2 = _make_fp_day(p2, child.id, ws, shared_date, is_dubbeldag=False)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p1, p2],
planned_days=[d1, d2],
)
tip = await _rule_use_dubbeldagar_strategically(ctx)
assert tip is not None
assert tip.id == "use_dubbeldagar_strategically"
assert tip.severity == "action"
assert tip.data["date"] == shared_date.isoformat()
async def test_no_fire_when_already_marked_dubbeldag(self):
ws = uuid4()
child = _make_child(date.today() - timedelta(days=30))
p1 = _make_parent(workspace_id=ws)
p2 = _make_parent(workspace_id=ws)
shared_date = date.today() + timedelta(days=10)
d1 = _make_fp_day(p1, child.id, ws, shared_date, is_dubbeldag=True)
d2 = _make_fp_day(p2, child.id, ws, shared_date, is_dubbeldag=False)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p1, p2],
planned_days=[d1, d2],
)
tip = await _rule_use_dubbeldagar_strategically(ctx)
assert tip is None
async def test_no_fire_with_single_parent(self):
ws = uuid4()
child = _make_child(date.today() - timedelta(days=30))
p1 = _make_parent(workspace_id=ws)
d1 = _make_fp_day(p1, child.id, ws, date.today() + timedelta(days=5))
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p1],
planned_days=[d1],
)
tip = await _rule_use_dubbeldagar_strategically(ctx)
assert tip is None
async def test_no_fire_outside_dubbeldagar_window(self):
ws = uuid4()
# Child born 16 months ago → outside 15-month window
child = _make_child(date.today() - relativedelta(months=16))
p1 = _make_parent(workspace_id=ws)
p2 = _make_parent(workspace_id=ws)
# Shared date is today, which is outside the window
shared_date = date.today() + timedelta(days=5)
d1 = _make_fp_day(p1, child.id, ws, shared_date, is_dubbeldag=False)
d2 = _make_fp_day(p2, child.id, ws, shared_date, is_dubbeldag=False)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p1, p2],
planned_days=[d1, d2],
)
tip = await _rule_use_dubbeldagar_strategically(ctx)
assert tip is None
# ---------------------------------------------------------------------------
# _rule_lagsta_niva_coverage
# ---------------------------------------------------------------------------
class TestLagstaNivaCoverage:
async def test_fires_when_sjukpenning_low_and_lagsta_available(self):
ws = uuid4()
p = _make_parent(workspace_id=ws)
child = _make_child(date.today() - relativedelta(years=1))
leave = _make_leave(p, child.id, sjukpenning_left=10, lagsta_left=30)
ctx = _make_ctx(
workspace_id=ws,
parents=[p],
leave_by_parent={p.uuid: leave},
)
tip = await _rule_lagsta_niva_coverage(ctx)
assert tip is not None
assert tip.id == "lagsta_niva_coverage"
assert tip.data["sjukpenning_left"] == 10
assert tip.data["lagsta_left"] == 30
async def test_no_fire_when_sjukpenning_ample(self):
ws = uuid4()
p = _make_parent(workspace_id=ws)
child = _make_child(date.today() - relativedelta(years=1))
leave = _make_leave(p, child.id, sjukpenning_left=100, lagsta_left=30)
ctx = _make_ctx(
workspace_id=ws,
parents=[p],
leave_by_parent={p.uuid: leave},
)
tip = await _rule_lagsta_niva_coverage(ctx)
assert tip is None
async def test_no_fire_when_lagsta_exhausted(self):
ws = uuid4()
p = _make_parent(workspace_id=ws)
child = _make_child(date.today() - relativedelta(years=1))
leave = _make_leave(p, child.id, sjukpenning_left=5, lagsta_left=0)
ctx = _make_ctx(
workspace_id=ws,
parents=[p],
leave_by_parent={p.uuid: leave},
)
tip = await _rule_lagsta_niva_coverage(ctx)
assert tip is None
# ---------------------------------------------------------------------------
# _rule_stretch_time_at_home
# ---------------------------------------------------------------------------
class TestStretchTimeAtHome:
async def test_fires_when_high_intensity_and_low_projection(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws)
# 28 FP days in next 4 weeks → intensity = 7/week
today = date.today()
fp_days = [
_make_fp_day(p, child.id, ws, today + timedelta(days=i + 1))
for i in range(28)
]
# total=240, used=185, planned_fp_before_b4=28 → projected=27 < 60
leave = _make_leave(p, child.id, total=240, used=185)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p],
leave_by_parent={p.uuid: leave},
planned_days=fp_days,
)
tip = await _rule_stretch_time_at_home(ctx)
assert tip is not None
assert tip.id == "stretch_time_at_home"
assert tip.data["weekly_intensity"] == 7.0
async def test_no_fire_when_low_intensity(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws)
today = date.today()
# 14 FP days in next 4 weeks → intensity = 3.5/week < 7
fp_days = [
_make_fp_day(p, child.id, ws, today + timedelta(days=i * 2 + 1))
for i in range(14)
]
leave = _make_leave(p, child.id, total=240, used=185)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p],
leave_by_parent={p.uuid: leave},
planned_days=fp_days,
)
tip = await _rule_stretch_time_at_home(ctx)
assert tip is None
async def test_no_fire_when_high_intensity_but_ample_days_left(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws)
today = date.today()
fp_days = [
_make_fp_day(p, child.id, ws, today + timedelta(days=i + 1))
for i in range(28)
]
# total=240, used=50 → projected=240-50-28=162 ≥ 60 → no fire
leave = _make_leave(p, child.id, total=240, used=50)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p],
leave_by_parent={p.uuid: leave},
planned_days=fp_days,
)
tip = await _rule_stretch_time_at_home(ctx)
assert tip is None
async def test_no_fire_when_no_children(self):
ctx = _make_ctx()
tip = await _rule_stretch_time_at_home(ctx)
assert tip is None
# ---------------------------------------------------------------------------
# _rule_wrap_weekend_with_vacation
# ---------------------------------------------------------------------------
class TestWrapWeekendWithVacation:
async def test_fires_for_monthly_parent_with_fp_fri_and_mon(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws, salary_type="monthly")
friday = _next_weekday(4, offset_days=1)
monday = friday + timedelta(days=3)
d_fri = _make_fp_day(p, child.id, ws, friday)
d_mon = _make_fp_day(p, child.id, ws, monday)
ctx = _make_ctx(
workspace_id=ws,
planning_goal="max_money",
children=[child],
parents=[p],
planned_days=[d_fri, d_mon],
)
tip = await _rule_wrap_weekend_with_vacation(ctx)
assert tip is not None
assert tip.id == "wrap_weekend_with_vacation"
assert tip.data["friday"] == friday.isoformat()
assert tip.data["monday"] == monday.isoformat()
async def test_no_fire_for_hourly_parent(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws, salary_type="hourly")
friday = _next_weekday(4, offset_days=1)
monday = friday + timedelta(days=3)
ctx = _make_ctx(
workspace_id=ws,
planning_goal="max_money",
children=[child],
parents=[p],
planned_days=[
_make_fp_day(p, child.id, ws, friday),
_make_fp_day(p, child.id, ws, monday),
],
)
tip = await _rule_wrap_weekend_with_vacation(ctx)
assert tip is None
async def test_no_fire_when_goal_is_not_max_money(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws, salary_type="monthly")
friday = _next_weekday(4, offset_days=1)
monday = friday + timedelta(days=3)
ctx = _make_ctx(
workspace_id=ws,
planning_goal="max_time", # not max_money
children=[child],
parents=[p],
planned_days=[
_make_fp_day(p, child.id, ws, friday),
_make_fp_day(p, child.id, ws, monday),
],
)
tip = await _rule_wrap_weekend_with_vacation(ctx)
assert tip is None
async def test_no_fire_when_only_friday_planned(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws, salary_type="monthly")
friday = _next_weekday(4, offset_days=1)
ctx = _make_ctx(
workspace_id=ws,
planning_goal="max_money",
children=[child],
parents=[p],
planned_days=[_make_fp_day(p, child.id, ws, friday)],
)
tip = await _rule_wrap_weekend_with_vacation(ctx)
assert tip is None
# ---------------------------------------------------------------------------
# _rule_wrap_red_days_with_vacation
# ---------------------------------------------------------------------------
class TestWrapRedDaysWithVacation:
async def test_fires_when_fp_adjacent_to_cluster(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws, salary_type="monthly")
today = date.today()
look_ahead = today + timedelta(weeks=12)
clusters = red_day_clusters_in_range(today, look_ahead)
assert clusters, "No holiday clusters found — test precondition failed"
cluster_start, _ = clusters[0]
fp_date = cluster_start - timedelta(days=1)
ctx = _make_ctx(
workspace_id=ws,
planning_goal="max_money",
children=[child],
parents=[p],
planned_days=[_make_fp_day(p, child.id, ws, fp_date)],
)
tip = await _rule_wrap_red_days_with_vacation(ctx)
assert tip is not None
assert tip.id == "wrap_red_days_with_vacation"
assert tip.data["cluster_start"] == cluster_start.isoformat()
async def test_no_fire_when_goal_is_not_max_money(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws, salary_type="monthly")
ctx = _make_ctx(
workspace_id=ws,
planning_goal="balanced",
children=[child],
parents=[p],
)
tip = await _rule_wrap_red_days_with_vacation(ctx)
assert tip is None
async def test_no_fire_for_hourly_parent(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=1))
p = _make_parent(workspace_id=ws, salary_type="hourly")
today = date.today()
look_ahead = today + timedelta(weeks=12)
clusters = red_day_clusters_in_range(today, look_ahead)
if not clusters:
pytest.skip("No holiday clusters in window")
cluster_start, _ = clusters[0]
fp_date = cluster_start - timedelta(days=1)
ctx = _make_ctx(
workspace_id=ws,
planning_goal="max_money",
children=[child],
parents=[p],
planned_days=[_make_fp_day(p, child.id, ws, fp_date)],
)
tip = await _rule_wrap_red_days_with_vacation(ctx)
assert tip is None
# ---------------------------------------------------------------------------
# _rule_long_break_parent_swap
# ---------------------------------------------------------------------------
class TestLongBreakParentSwap:
async def test_fires_when_parent_uncovered_during_break(self):
ws = uuid4()
today = date.today()
p1 = _make_parent(workspace_id=ws, name="Alice")
p2 = _make_parent(workspace_id=ws, name="Bob")
# Break period 5 working days from now (Mon–Fri next week)
break_start = _next_weekday(0, offset_days=7) # next Monday
break_end = break_start + timedelta(days=4) # Friday
bp = _make_break(ws, break_start, break_end)
# Bob has full coverage during break; Alice has none
bob_days = [
_make_fp_day(p2, uuid4(), ws, break_start + timedelta(days=i))
for i in range(5)
if (break_start + timedelta(days=i)).weekday() < 5
]
ctx = _make_ctx(
workspace_id=ws,
children=[_make_child(today - relativedelta(years=1))],
parents=[p1, p2],
planned_days=bob_days,
break_periods=[bp],
)
tip = await _rule_long_break_parent_swap(ctx)
assert tip is not None
assert tip.id == "long_break_parent_swap"
assert tip.data["break_name"] == "Summer"
assert tip.data["parent_name"] == "Alice"
assert tip.data["other_parent_name"] == "Bob"
async def test_no_fire_when_both_parents_covered(self):
ws = uuid4()
today = date.today()
p1 = _make_parent(workspace_id=ws, name="Alice")
p2 = _make_parent(workspace_id=ws, name="Bob")
break_start = _next_weekday(0, offset_days=7)
break_end = break_start + timedelta(days=4)
bp = _make_break(ws, break_start, break_end)
# Both parents have ≥50% coverage
all_days = []
for p in [p1, p2]:
for i in range(5):
d = break_start + timedelta(days=i)
if d.weekday() < 5:
all_days.append(_make_fp_day(p, uuid4(), ws, d))
ctx = _make_ctx(
workspace_id=ws,
children=[_make_child(today - relativedelta(years=1))],
parents=[p1, p2],
planned_days=all_days,
break_periods=[bp],
)
tip = await _rule_long_break_parent_swap(ctx)
assert tip is None
async def test_no_fire_with_single_parent(self):
ws = uuid4()
p = _make_parent(workspace_id=ws)
break_start = _next_weekday(0, offset_days=7)
bp = _make_break(ws, break_start, break_start + timedelta(days=4))
ctx = _make_ctx(workspace_id=ws, parents=[p], break_periods=[bp])
tip = await _rule_long_break_parent_swap(ctx)
assert tip is None
async def test_no_fire_for_past_break(self):
ws = uuid4()
p1 = _make_parent(workspace_id=ws)
p2 = _make_parent(workspace_id=ws)
past_start = date.today() - timedelta(days=30)
past_end = date.today() - timedelta(days=23)
bp = _make_break(ws, past_start, past_end)
ctx = _make_ctx(workspace_id=ws, parents=[p1, p2], break_periods=[bp])
tip = await _rule_long_break_parent_swap(ctx)
assert tip is None
# ---------------------------------------------------------------------------
# _rule_reserve_for_preschool_planning_days
# ---------------------------------------------------------------------------
class TestReserveForPreschoolPlanningDays:
async def test_fires_when_preschool_active_and_days_low(self):
ws = uuid4()
# Preschool started 6 months ago
preschool_start = date.today() - relativedelta(months=6)
child = _make_child(
date.today() - relativedelta(years=3),
preschool_start_date=preschool_start,
)
p = _make_parent(workspace_id=ws)
# days_left = 5 < buffer_needed * 2 = 12
leave = _make_leave(p, child.id, total=240, used=235)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p],
leave_by_parent={p.uuid: leave},
)
tip = await _rule_reserve_for_preschool_planning_days(ctx)
assert tip is not None
assert tip.id == "reserve_for_preschool_planning_days"
assert tip.data["buffer_days"] == 6
assert tip.data["days_left"] == 5
async def test_no_fire_when_no_preschool_date(self):
ws = uuid4()
child = _make_child(date.today() - relativedelta(years=3)) # no preschool_start_date
p = _make_parent(workspace_id=ws)
leave = _make_leave(p, child.id, total=240, used=235)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p],
leave_by_parent={p.uuid: leave},
)
tip = await _rule_reserve_for_preschool_planning_days(ctx)
assert tip is None
async def test_no_fire_when_days_left_sufficient(self):
ws = uuid4()
preschool_start = date.today() - relativedelta(months=6)
child = _make_child(
date.today() - relativedelta(years=3),
preschool_start_date=preschool_start,
)
p = _make_parent(workspace_id=ws)
# days_left = 100 >> 12 → no fire
leave = _make_leave(p, child.id, total=240, used=140)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p],
leave_by_parent={p.uuid: leave},
)
tip = await _rule_reserve_for_preschool_planning_days(ctx)
assert tip is None
async def test_no_fire_when_preschool_not_yet_started(self):
ws = uuid4()
preschool_start = date.today() + relativedelta(months=3) # future start
child = _make_child(
date.today() - relativedelta(years=2),
preschool_start_date=preschool_start,
)
p = _make_parent(workspace_id=ws)
leave = _make_leave(p, child.id, total=240, used=235)
ctx = _make_ctx(
workspace_id=ws,
children=[child],
parents=[p],
leave_by_parent={p.uuid: leave},
)
tip = await _rule_reserve_for_preschool_planning_days(ctx)
assert tip is None
@@ -0,0 +1,96 @@
import { describe, it, expect, vi } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { CalendarDay } from './CalendarDay'
describe('CalendarDay', () => {
it('renders day number', () => {
const { getByTestId } = render(<CalendarDay date="2026-06-15" />)
const day = getByTestId('calendar-day')
expect(day).toBeInTheDocument()
expect(day.textContent).toContain('15')
})
it('renders as disabled when isCurrentMonth is false', () => {
const { getByTestId } = render(
<CalendarDay date="2026-06-15" isCurrentMonth={false} />
)
const day = getByTestId('calendar-day')
expect(day).toHaveClass('calendar-day--disabled')
})
it('renders events when provided', () => {
const { getByTestId } = render(
<CalendarDay
date="2026-06-15"
events={[
{ type: 'vacation', color: '#FF5733', title: 'Vacation' },
{ type: 'sick', color: '#FFD700', title: 'Sick leave' },
]}
/>
)
const events = getByTestId('calendar-day-events')
expect(events).toBeInTheDocument()
expect(events.children).toHaveLength(2)
})
it('applies has-events class when events exist', () => {
const { getByTestId } = render(
<CalendarDay
date="2026-06-15"
events={[{ type: 'vacation', color: '#FF5733' }]}
/>
)
const day = getByTestId('calendar-day')
expect(day).toHaveClass('calendar-day--has-events')
})
it('calls onClick when clicked', () => {
const onClick = vi.fn()
const { getByTestId } = render(
<CalendarDay date="2026-06-15" onClick={onClick} />
)
const day = getByTestId('calendar-day')
fireEvent.click(day)
expect(onClick).toHaveBeenCalledTimes(1)
})
it('calls onClick on Enter key', () => {
const onClick = vi.fn()
const { getByTestId } = render(
<CalendarDay date="2026-06-15" onClick={onClick} />
)
const day = getByTestId('calendar-day')
fireEvent.keyDown(day, { key: 'Enter' })
expect(onClick).toHaveBeenCalledTimes(1)
})
it('calls onClick on Space key', () => {
const onClick = vi.fn()
const { getByTestId } = render(
<CalendarDay date="2026-06-15" onClick={onClick} />
)
const day = getByTestId('calendar-day')
fireEvent.keyDown(day, { key: ' ' })
expect(onClick).toHaveBeenCalledTimes(1)
})
it('provides accessibility label with event count', () => {
const { getByTestId } = render(
<CalendarDay
date="2026-06-15"
events={[
{ type: 'vacation', color: '#FF5733' },
{ type: 'sick', color: '#FFD700' },
]}
/>
)
const day = getByTestId('calendar-day')
expect(day).toHaveAttribute('aria-label', '2026-06-15 with 2 event(s)')
})
it('renders without events by default', () => {
const { queryByTestId } = render(<CalendarDay date="2026-06-15" />)
const events = queryByTestId('calendar-day-events')
expect(events).not.toBeInTheDocument()
})
})
@@ -0,0 +1,48 @@
import { ReactNode } from 'react'
export interface CalendarDayProps {
date: string
events?: { type: string; color: string; title?: string }[]
isCurrentMonth?: boolean
onClick?: () => void
}
export function CalendarDay({
date,
events = [],
isCurrentMonth = true,
onClick,
}: CalendarDayProps): ReactNode {
const dayNum = new Date(date + 'T00:00:00Z').getUTCDate()
return (
<div
data-testid="calendar-day"
onClick={onClick}
role="button"
tabIndex={0}
aria-label={`${date}${events.length > 0 ? ` with ${events.length} event(s)` : ''}`}
className={`calendar-day ${!isCurrentMonth ? 'calendar-day--disabled' : ''} ${events.length > 0 ? 'calendar-day--has-events' : ''}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onClick?.()
}
}}
>
<div className="calendar-day__number">{dayNum}</div>
{events.length > 0 && (
<div className="calendar-day__events" data-testid="calendar-day-events">
{events.map((event, idx) => (
<div
key={idx}
className="calendar-day__event"
style={{ backgroundColor: event.color }}
title={event.title ?? event.type}
data-testid={`event-${idx}`}
/>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,75 @@
import { describe, it, expect, vi } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { CalendarEvent } from './CalendarEvent'
describe('CalendarEvent', () => {
it('renders event with leave type label', () => {
const { getByTestId } = render(
<CalendarEvent leaveType="vacation" color="#FF5733" />
)
const event = getByTestId('calendar-event')
expect(event).toBeInTheDocument()
expect(event.textContent).toContain('vacation')
})
it('applies color style', () => {
const { getByTestId } = render(
<CalendarEvent leaveType="vacation" color="#FF5733" />
)
const event = getByTestId('calendar-event')
expect(event).toHaveStyle({ backgroundColor: '#FF5733' })
})
it('displays custom title on hover', () => {
const { getByTitle } = render(
<CalendarEvent leaveType="vacation" color="#FF5733" title="Summer vacation" />
)
expect(getByTitle('Summer vacation')).toBeInTheDocument()
})
it('renders dismiss button when onDismiss provided', () => {
const { getByTestId } = render(
<CalendarEvent leaveType="vacation" color="#FF5733" onDismiss={() => {}} />
)
const button = getByTestId('dismiss-button')
expect(button).toBeInTheDocument()
expect(button.textContent).toBe('×')
})
it('does not render dismiss button when onDismiss not provided', () => {
const { queryByTestId } = render(
<CalendarEvent leaveType="vacation" color="#FF5733" />
)
expect(queryByTestId('dismiss-button')).not.toBeInTheDocument()
})
it('calls onDismiss when dismiss button clicked', () => {
const onDismiss = vi.fn()
const { getByTestId } = render(
<CalendarEvent leaveType="vacation" color="#FF5733" onDismiss={onDismiss} />
)
const button = getByTestId('dismiss-button')
fireEvent.click(button)
expect(onDismiss).toHaveBeenCalledTimes(1)
})
it('stops propagation on dismiss button click', () => {
const onDismiss = vi.fn()
const { getByTestId } = render(
<CalendarEvent leaveType="vacation" color="#FF5733" onDismiss={onDismiss} />
)
const button = getByTestId('dismiss-button')
const event = new MouseEvent('click', { bubbles: true })
const stopPropagation = vi.spyOn(event, 'stopPropagation')
fireEvent(button, event)
expect(stopPropagation).toHaveBeenCalled()
})
it('provides accessibility label', () => {
const { getByTestId } = render(
<CalendarEvent leaveType="vacation" color="#FF5733" onDismiss={() => {}} />
)
const button = getByTestId('dismiss-button')
expect(button).toHaveAttribute('aria-label', 'Dismiss vacation')
})
})
@@ -0,0 +1,40 @@
import { ReactNode } from 'react'
export interface CalendarEventProps {
leaveType: string
color: string
title?: string
onDismiss?: () => void
}
export function CalendarEvent({
leaveType,
color,
title,
onDismiss,
}: CalendarEventProps): ReactNode {
return (
<div
data-testid="calendar-event"
className="calendar-event"
style={{ backgroundColor: color }}
>
<span className="calendar-event__label" title={title ?? leaveType}>
{leaveType}
</span>
{onDismiss && (
<button
className="calendar-event__dismiss"
onClick={(e) => {
e.stopPropagation()
onDismiss()
}}
aria-label={`Dismiss ${leaveType}`}
data-testid="dismiss-button"
>
×
</button>
)}
</div>
)
}
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { DaysBreakdown } from './DaysBreakdown'
describe('DaysBreakdown', () => {
it('renders empty state when no breakdown provided', () => {
const { getByTestId } = render(<DaysBreakdown breakdown={[]} />)
expect(getByTestId('days-breakdown').textContent).toContain('No days recorded yet')
})
it('renders table with breakdown data', () => {
const breakdown = [
{ type: 'vacation', count: 10 },
{ type: 'sick leave', count: 3 },
{ type: 'parental', count: 50 },
]
const { getByTestId } = render(<DaysBreakdown breakdown={breakdown} />)
const table = getByTestId('days-breakdown')
expect(table.querySelector('table')).toBeInTheDocument()
expect(table.textContent).toContain('vacation')
expect(table.textContent).toContain('10')
})
it('renders correct number of rows', () => {
const breakdown = [
{ type: 'vacation', count: 10 },
{ type: 'sick leave', count: 3 },
{ type: 'parental', count: 50 },
]
const { getByTestId } = render(<DaysBreakdown breakdown={breakdown} />)
expect(getByTestId('breakdown-row-vacation')).toBeInTheDocument()
expect(getByTestId('breakdown-row-sick leave')).toBeInTheDocument()
expect(getByTestId('breakdown-row-parental')).toBeInTheDocument()
})
it('displays correct values in table', () => {
const breakdown = [{ type: 'vacation', count: 25 }]
const { getByTestId } = render(<DaysBreakdown breakdown={breakdown} />)
const row = getByTestId('breakdown-row-vacation')
expect(row.textContent).toContain('vacation')
expect(row.textContent).toContain('25')
})
it('renders table headers', () => {
const breakdown = [{ type: 'vacation', count: 10 }]
const { getByTestId } = render(<DaysBreakdown breakdown={breakdown} />)
const table = getByTestId('days-breakdown').querySelector('table')
expect(table?.querySelector('thead')).toBeInTheDocument()
})
})
@@ -0,0 +1,36 @@
import { ReactNode } from 'react'
export interface DaysBreakdownProps {
breakdown: Array<{ type: string; count: number }>
}
export function DaysBreakdown({ breakdown }: DaysBreakdownProps): ReactNode {
if (!breakdown || breakdown.length === 0) {
return (
<div data-testid="days-breakdown" className="days-breakdown">
<p className="days-breakdown__empty">No days recorded yet</p>
</div>
)
}
return (
<div data-testid="days-breakdown" className="days-breakdown">
<table className="days-breakdown__table">
<thead>
<tr>
<th>Leave Type</th>
<th>Days</th>
</tr>
</thead>
<tbody>
{breakdown.map((item, idx) => (
<tr key={idx} data-testid={`breakdown-row-${item.type}`}>
<td>{item.type}</td>
<td>{item.count}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { ProgressChart } from './ProgressChart'
describe('ProgressChart', () => {
it('renders progress chart', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={10} targetWeeks={50} />
)
expect(getByTestId('progress-chart')).toBeInTheDocument()
})
it('calculates percentage correctly', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={25} targetWeeks={100} />
)
expect(getByTestId('percentage').textContent).toBe('25%')
})
it('displays weeks correctly', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={10} targetWeeks={50} />
)
expect(getByTestId('current-weeks').textContent).toBe('10 / 50 weeks')
})
it('sets fill width based on percentage', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={50} targetWeeks={100} />
)
const fill = getByTestId('progress-fill')
expect(fill).toHaveStyle({ width: '50%' })
})
it('caps percentage at 100%', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={150} targetWeeks={100} />
)
expect(getByTestId('percentage').textContent).toBe('100%')
expect(getByTestId('progress-fill')).toHaveStyle({ width: '100%' })
})
it('shows 0% when no progress', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={0} targetWeeks={50} />
)
expect(getByTestId('percentage').textContent).toBe('0%')
})
it('displays child age when provided', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={10} targetWeeks={50} childAge={2} />
)
expect(getByTestId('progress-chart').textContent).toContain('Age: 2')
})
it('handles zero target weeks', () => {
const { getByTestId } = render(
<ProgressChart currentWeeks={10} targetWeeks={0} />
)
expect(getByTestId('percentage').textContent).toBe('0%')
})
})
@@ -0,0 +1,39 @@
import { ReactNode } from 'react'
export interface ProgressChartProps {
currentWeeks: number
targetWeeks: number
childAge?: number
}
export function ProgressChart({
currentWeeks,
targetWeeks,
childAge,
}: ProgressChartProps): ReactNode {
const percentage = targetWeeks > 0 ? Math.min((currentWeeks / targetWeeks) * 100, 100) : 0
return (
<div data-testid="progress-chart" className="progress-chart">
<div className="progress-chart__header">
<h3>Parental Leave Progress</h3>
{childAge !== undefined && <span className="progress-chart__age">Age: {childAge}</span>}
</div>
<div className="progress-chart__bar">
<div
className="progress-chart__fill"
style={{ width: `${percentage}%` }}
data-testid="progress-fill"
/>
</div>
<div className="progress-chart__stats">
<span data-testid="current-weeks">
{currentWeeks} / {targetWeeks} weeks
</span>
<span data-testid="percentage" className="progress-chart__percentage">
{Math.round(percentage)}%
</span>
</div>
</div>
)
}
@@ -0,0 +1,116 @@
import { describe, it, expect, vi } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { BreakPeriodsInput } from './BreakPeriodsInput'
describe('BreakPeriodsInput', () => {
const mockPeriods = [
{
id: '1',
name: 'Summer',
startDate: '2026-06-01',
endDate: '2026-08-31',
targetWeeks: 12,
},
]
it('renders break periods list', () => {
const { getByTestId } = render(
<BreakPeriodsInput periods={mockPeriods} onAdd={() => {}} onRemove={() => {}} />
)
expect(getByTestId('period-1')).toBeInTheDocument()
expect(getByTestId('period-1').textContent).toContain('Summer')
})
it('shows empty state when no periods', () => {
const { getByTestId } = render(
<BreakPeriodsInput periods={[]} onAdd={() => {}} onRemove={() => {}} />
)
expect(getByTestId('empty-state')).toBeInTheDocument()
})
it('displays add button', () => {
const { getByTestId } = render(
<BreakPeriodsInput periods={[]} onAdd={() => {}} onRemove={() => {}} />
)
expect(getByTestId('add-button')).toBeInTheDocument()
})
it('shows form when add button clicked', () => {
const { getByTestId, queryByTestId } = render(
<BreakPeriodsInput periods={[]} onAdd={() => {}} onRemove={() => {}} />
)
expect(queryByTestId('add-form')).not.toBeInTheDocument()
fireEvent.click(getByTestId('add-button'))
expect(getByTestId('add-form')).toBeInTheDocument()
})
it('calls onAdd with form data', () => {
const onAdd = vi.fn()
const { getByTestId } = render(
<BreakPeriodsInput periods={[]} onAdd={onAdd} onRemove={() => {}} />
)
fireEvent.click(getByTestId('add-button'))
fireEvent.change(getByTestId('period-name'), { target: { value: 'Winter' } })
fireEvent.change(getByTestId('period-start'), { target: { value: '2026-12-01' } })
fireEvent.change(getByTestId('period-end'), { target: { value: '2026-12-31' } })
fireEvent.change(getByTestId('period-weeks'), { target: { value: '4' } })
fireEvent.click(getByTestId('submit-button'))
expect(onAdd).toHaveBeenCalledWith({
name: 'Winter',
startDate: '2026-12-01',
endDate: '2026-12-31',
targetWeeks: 4,
})
})
it('calls onRemove when remove button clicked', () => {
const onRemove = vi.fn()
const { getByTestId } = render(
<BreakPeriodsInput periods={mockPeriods} onAdd={() => {}} onRemove={onRemove} />
)
fireEvent.click(getByTestId('remove-1'))
expect(onRemove).toHaveBeenCalledWith('1')
})
it('closes form on cancel', () => {
const { getByTestId, queryByTestId } = render(
<BreakPeriodsInput periods={[]} onAdd={() => {}} onRemove={() => {}} />
)
fireEvent.click(getByTestId('add-button'))
expect(getByTestId('add-form')).toBeInTheDocument()
fireEvent.click(getByTestId('cancel-button'))
expect(queryByTestId('add-form')).not.toBeInTheDocument()
})
it('disables submit when fields incomplete', () => {
const { getByTestId } = render(
<BreakPeriodsInput periods={[]} onAdd={() => {}} onRemove={() => {}} />
)
fireEvent.click(getByTestId('add-button'))
const submit = getByTestId('submit-button') as HTMLButtonElement
expect(submit.disabled).toBe(true)
fireEvent.change(getByTestId('period-name'), { target: { value: 'Winter' } })
fireEvent.change(getByTestId('period-start'), { target: { value: '2026-12-01' } })
fireEvent.change(getByTestId('period-end'), { target: { value: '2026-12-31' } })
expect(submit.disabled).toBe(false)
})
it('disables buttons when loading', () => {
const { getByTestId } = render(
<BreakPeriodsInput
periods={mockPeriods}
onAdd={() => {}}
onRemove={() => {}}
isLoading
/>
)
expect((getByTestId('add-button') as HTMLButtonElement).disabled).toBe(true)
expect((getByTestId('remove-1') as HTMLButtonElement).disabled).toBe(true)
})
})
@@ -0,0 +1,153 @@
import { ReactNode, useState } from 'react'
export interface BreakPeriodsInputProps {
periods: Array<{ id: string; name: string; startDate: string; endDate: string; targetWeeks: number }>
onAdd: (period: Omit<any, 'id'>) => void
onRemove: (id: string) => void
isLoading?: boolean
}
export function BreakPeriodsInput({
periods,
onAdd,
onRemove,
isLoading = false,
}: BreakPeriodsInputProps): ReactNode {
const [formOpen, setFormOpen] = useState(false)
const [name, setName] = useState('')
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [targetWeeks, setTargetWeeks] = useState('0')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (name && startDate && endDate && targetWeeks) {
onAdd({ name, startDate, endDate, targetWeeks: parseInt(targetWeeks, 10) })
setName('')
setStartDate('')
setEndDate('')
setTargetWeeks('0')
setFormOpen(false)
}
}
return (
<div data-testid="break-periods-input" className="break-periods-input">
<h3 className="break-periods-input__title">Break Periods</h3>
{periods.length === 0 ? (
<p className="break-periods-input__empty" data-testid="empty-state">
No break periods defined yet
</p>
) : (
<ul className="break-periods-input__list">
{periods.map((period) => (
<li
key={period.id}
className="break-periods-input__item"
data-testid={`period-${period.id}`}
>
<div className="break-periods-input__info">
<strong>{period.name}</strong>
<small>
{period.startDate} to {period.endDate} ({period.targetWeeks} weeks)
</small>
</div>
<button
className="break-periods-input__remove"
onClick={() => onRemove(period.id)}
disabled={isLoading}
aria-label={`Remove ${period.name}`}
data-testid={`remove-${period.id}`}
>
Remove
</button>
</li>
))}
</ul>
)}
{formOpen && (
<form
onSubmit={handleSubmit}
className="break-periods-input__form"
data-testid="add-form"
>
<div className="break-periods-input__field">
<label htmlFor="period-name">Name</label>
<input
id="period-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Summer"
data-testid="period-name"
/>
</div>
<div className="break-periods-input__field">
<label htmlFor="period-start">Start Date</label>
<input
id="period-start"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
data-testid="period-start"
/>
</div>
<div className="break-periods-input__field">
<label htmlFor="period-end">End Date</label>
<input
id="period-end"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
data-testid="period-end"
/>
</div>
<div className="break-periods-input__field">
<label htmlFor="period-weeks">Target Weeks</label>
<input
id="period-weeks"
type="number"
min="0"
value={targetWeeks}
onChange={(e) => setTargetWeeks(e.target.value)}
data-testid="period-weeks"
/>
</div>
<div className="break-periods-input__actions">
<button
type="submit"
disabled={isLoading || !name || !startDate || !endDate}
data-testid="submit-button"
>
Add Period
</button>
<button
type="button"
onClick={() => setFormOpen(false)}
data-testid="cancel-button"
>
Cancel
</button>
</div>
</form>
)}
{!formOpen && (
<button
onClick={() => setFormOpen(true)}
className="break-periods-input__add"
disabled={isLoading}
data-testid="add-button"
>
+ Add Break Period
</button>
)}
</div>
)
}
@@ -0,0 +1,81 @@
import { describe, it, expect, vi } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { ParentProfileInput } from './ParentProfileInput'
describe('ParentProfileInput', () => {
it('renders salary type and employment status selects', () => {
const { getByTestId } = render(
<ParentProfileInput parent={{}} onChange={() => {}} />
)
expect(getByTestId('salary-type')).toBeInTheDocument()
expect(getByTestId('employment-status')).toBeInTheDocument()
})
it('displays current salary type', () => {
const { getByTestId } = render(
<ParentProfileInput
parent={{ salaryType: 'hourly' }}
onChange={() => {}}
/>
)
const select = getByTestId('salary-type') as HTMLSelectElement
expect(select.value).toBe('hourly')
})
it('displays current employment status', () => {
const { getByTestId } = render(
<ParentProfileInput
parent={{ employmentStatus: 'self_employed' }}
onChange={() => {}}
/>
)
const select = getByTestId('employment-status') as HTMLSelectElement
expect(select.value).toBe('self_employed')
})
it('calls onChange for salary type change', () => {
const onChange = vi.fn()
const { getByTestId } = render(
<ParentProfileInput parent={{}} onChange={onChange} />
)
const select = getByTestId('salary-type')
fireEvent.change(select, { target: { value: 'monthly' } })
expect(onChange).toHaveBeenCalledWith('salaryType', 'monthly')
})
it('calls onChange for employment status change', () => {
const onChange = vi.fn()
const { getByTestId } = render(
<ParentProfileInput parent={{}} onChange={onChange} />
)
const select = getByTestId('employment-status')
fireEvent.change(select, { target: { value: 'employed' } })
expect(onChange).toHaveBeenCalledWith('employmentStatus', 'employed')
})
it('renders all salary type options', () => {
const { getByTestId } = render(
<ParentProfileInput parent={{}} onChange={() => {}} />
)
const select = getByTestId('salary-type')
expect((select as HTMLSelectElement).options.length).toBeGreaterThanOrEqual(4)
})
it('renders all employment status options', () => {
const { getByTestId } = render(
<ParentProfileInput parent={{}} onChange={() => {}} />
)
const select = getByTestId('employment-status')
expect((select as HTMLSelectElement).options.length).toBeGreaterThanOrEqual(4)
})
it('handles undefined parent properties', () => {
const { getByTestId } = render(
<ParentProfileInput parent={{}} onChange={() => {}} />
)
const salaryType = getByTestId('salary-type') as HTMLSelectElement
const employmentStatus = getByTestId('employment-status') as HTMLSelectElement
expect(salaryType.value).toBe('')
expect(employmentStatus.value).toBe('')
})
})
@@ -0,0 +1,54 @@
import { ReactNode } from 'react'
export interface ParentProfileInputProps {
parent: {
salaryType?: string
employmentStatus?: string
}
onChange: (field: string, val: string) => void
}
export function ParentProfileInput({
parent,
onChange,
}: ParentProfileInputProps): ReactNode {
return (
<div data-testid="parent-profile-input" className="parent-profile-input">
<div className="parent-profile-input__field">
<label htmlFor="salary-type" className="parent-profile-input__label">
Salary Type
</label>
<select
id="salary-type"
value={parent.salaryType || ''}
onChange={(e) => onChange('salaryType', e.target.value)}
data-testid="salary-type"
className="parent-profile-input__select"
>
<option value="">Choose salary type</option>
<option value="monthly">Monthly</option>
<option value="hourly">Hourly</option>
<option value="self_employed">Self Employed</option>
</select>
</div>
<div className="parent-profile-input__field">
<label htmlFor="employment-status" className="parent-profile-input__label">
Employment Status
</label>
<select
id="employment-status"
value={parent.employmentStatus || ''}
onChange={(e) => onChange('employmentStatus', e.target.value)}
data-testid="employment-status"
className="parent-profile-input__select"
>
<option value="">Choose employment status</option>
<option value="employed">Employed</option>
<option value="self_employed">Self Employed</option>
<option value="parental_leave">On Parental Leave</option>
</select>
</div>
</div>
)
}
@@ -0,0 +1,74 @@
import { describe, it, expect, vi } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { PlanningGoalSelector } from './PlanningGoalSelector'
describe('PlanningGoalSelector', () => {
it('renders three planning goal options', () => {
const { getByTestId } = render(
<PlanningGoalSelector onChange={() => {}} />
)
expect(getByTestId('goal-maximize-contiguous')).toBeInTheDocument()
expect(getByTestId('goal-minimize-stress')).toBeInTheDocument()
expect(getByTestId('goal-balance')).toBeInTheDocument()
})
it('renders as radio buttons', () => {
const { getByTestId } = render(
<PlanningGoalSelector onChange={() => {}} />
)
const radio1 = getByTestId('goal-maximize-contiguous') as HTMLInputElement
const radio2 = getByTestId('goal-minimize-stress') as HTMLInputElement
expect(radio1.type).toBe('radio')
expect(radio2.type).toBe('radio')
})
it('checks selected value', () => {
const { getByTestId } = render(
<PlanningGoalSelector value="minimize_stress" onChange={() => {}} />
)
const radio = getByTestId('goal-minimize-stress') as HTMLInputElement
expect(radio.checked).toBe(true)
})
it('calls onChange when selecting option', () => {
const onChange = vi.fn()
const { getByTestId } = render(
<PlanningGoalSelector onChange={onChange} />
)
const radio = getByTestId('goal-balance')
fireEvent.click(radio)
expect(onChange).toHaveBeenCalledWith('balance')
})
it('updates selection when value changes', () => {
const { getByTestId, rerender } = render(
<PlanningGoalSelector value="maximize_contiguous" onChange={() => {}} />
)
let radio = getByTestId('goal-maximize-contiguous') as HTMLInputElement
expect(radio.checked).toBe(true)
rerender(<PlanningGoalSelector value="balance" onChange={() => {}} />)
radio = getByTestId('goal-balance') as HTMLInputElement
expect(radio.checked).toBe(true)
})
it('only one option can be selected at a time', () => {
const { getByTestId } = render(
<PlanningGoalSelector value="maximize_contiguous" onChange={() => {}} />
)
const radio1 = getByTestId('goal-maximize-contiguous') as HTMLInputElement
const radio2 = getByTestId('goal-minimize-stress') as HTMLInputElement
expect(radio1.checked).toBe(true)
expect(radio2.checked).toBe(false)
})
it('has no selection when value is undefined', () => {
const { getByTestId } = render(
<PlanningGoalSelector onChange={() => {}} />
)
const radio1 = getByTestId('goal-maximize-contiguous') as HTMLInputElement
const radio2 = getByTestId('goal-minimize-stress') as HTMLInputElement
expect(radio1.checked).toBe(false)
expect(radio2.checked).toBe(false)
})
})
@@ -0,0 +1,55 @@
import { ReactNode } from 'react'
export interface PlanningGoalSelectorProps {
value?: string
onChange: (goal: string) => void
}
export function PlanningGoalSelector({ value, onChange }: PlanningGoalSelectorProps): ReactNode {
return (
<div data-testid="planning-goal-selector" className="planning-goal-selector">
<fieldset className="planning-goal-selector__fieldset">
<legend className="planning-goal-selector__legend">Planning Goal</legend>
<div className="planning-goal-selector__option">
<input
type="radio"
id="goal-maximize-contiguous"
name="planning-goal"
value="maximize_contiguous"
checked={value === 'maximize_contiguous'}
onChange={(e) => onChange(e.target.value)}
data-testid="goal-maximize-contiguous"
/>
<label htmlFor="goal-maximize-contiguous">Maximize Contiguous</label>
</div>
<div className="planning-goal-selector__option">
<input
type="radio"
id="goal-minimize-stress"
name="planning-goal"
value="minimize_stress"
checked={value === 'minimize_stress'}
onChange={(e) => onChange(e.target.value)}
data-testid="goal-minimize-stress"
/>
<label htmlFor="goal-minimize-stress">Minimize Stress</label>
</div>
<div className="planning-goal-selector__option">
<input
type="radio"
id="goal-balance"
name="planning-goal"
value="balance"
checked={value === 'balance'}
onChange={(e) => onChange(e.target.value)}
data-testid="goal-balance"
/>
<label htmlFor="goal-balance">Balance</label>
</div>
</fieldset>
</div>
)
}
@@ -0,0 +1,72 @@
import { describe, it, expect, vi } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { TipCard } from './TipCard'
describe('TipCard', () => {
const mockTip = {
id: 'tip1',
severity: 'info' as const,
messageKey: 'wrap_red_days',
data: {},
}
it('renders tip card with title', () => {
const { getByTestId } = render(<TipCard tip={mockTip} />)
expect(getByTestId('tip-card')).toBeInTheDocument()
expect(getByTestId('tip-card').textContent).toContain('Wrap Red Days')
})
it('applies severity class', () => {
const { getByTestId } = render(<TipCard tip={mockTip} />)
expect(getByTestId('tip-card')).toHaveClass('tip-card--info')
})
it('renders warning severity class', () => {
const warningTip = { ...mockTip, severity: 'warning' as const }
const { getByTestId } = render(<TipCard tip={warningTip} />)
expect(getByTestId('tip-card')).toHaveClass('tip-card--warning')
})
it('renders action severity class', () => {
const actionTip = { ...mockTip, severity: 'action' as const }
const { getByTestId } = render(<TipCard tip={actionTip} />)
expect(getByTestId('tip-card')).toHaveClass('tip-card--action')
})
it('renders dismiss button when onDismiss provided', () => {
const { getByTestId } = render(
<TipCard tip={mockTip} onDismiss={() => {}} />
)
expect(getByTestId('dismiss-button')).toBeInTheDocument()
})
it('does not render dismiss button when onDismiss not provided', () => {
const { queryByTestId } = render(<TipCard tip={mockTip} />)
expect(queryByTestId('dismiss-button')).not.toBeInTheDocument()
})
it('calls onDismiss with tip id when dismissed', () => {
const onDismiss = vi.fn()
const { getByTestId } = render(
<TipCard tip={mockTip} onDismiss={onDismiss} />
)
fireEvent.click(getByTestId('dismiss-button'))
expect(onDismiss).toHaveBeenCalledWith('tip1')
})
it('displays message key as body text', () => {
const { getByTestId } = render(<TipCard tip={mockTip} />)
expect(getByTestId('tip-card').textContent).toContain('wrap_red_days')
})
it('provides accessibility label', () => {
const { getByTestId } = render(<TipCard tip={mockTip} />)
expect(getByTestId('tip-card')).toHaveAttribute('aria-label', 'Tip: Wrap Red Days')
})
it('maps unknown messageKey to itself', () => {
const unknownTip = { ...mockTip, messageKey: 'unknown_key' }
const { getByTestId } = render(<TipCard tip={unknownTip} />)
expect(getByTestId('tip-card').textContent).toContain('unknown_key')
})
})
@@ -0,0 +1,51 @@
import { ReactNode } from 'react'
export interface TipCardProps {
tip: {
id: string
severity: 'info' | 'warning' | 'action'
messageKey: string
data: Record<string, unknown>
}
onDismiss?: (tipId: string) => void
}
export function TipCard({ tip, onDismiss }: TipCardProps): ReactNode {
const titleMap: Record<string, string> = {
wrap_red_days: 'Wrap Red Days',
prioritize_dubbeldagar: 'Use Dubbeldagar',
extend_contiguous: 'Extend Contiguous Leave',
}
const title = titleMap[tip.messageKey] || tip.messageKey
const severityClassMap = {
info: 'tip-card--info',
warning: 'tip-card--warning',
action: 'tip-card--action',
}
return (
<div
data-testid="tip-card"
className={`tip-card ${severityClassMap[tip.severity]}`}
role="article"
aria-label={`Tip: ${title}`}
>
<div className="tip-card__header">
<h4 className="tip-card__title">{title}</h4>
{onDismiss && (
<button
className="tip-card__dismiss"
onClick={() => onDismiss(tip.id)}
aria-label={`Dismiss: ${title}`}
data-testid="dismiss-button"
>
×
</button>
)}
</div>
<p className="tip-card__body">{tip.messageKey}</p>
</div>
)
}
@@ -0,0 +1,85 @@
import { describe, it, expect, vi } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { VacationDaysInput } from './VacationDaysInput'
describe('VacationDaysInput', () => {
it('renders input with label', () => {
const { getByTestId, getByText } = render(
<VacationDaysInput label="Annual vacation days" value={25} onChange={() => {}} />
)
expect(getByTestId('vacation-days-input')).toBeInTheDocument()
expect(getByText('Annual vacation days')).toBeInTheDocument()
})
it('displays current value', () => {
const { getByTestId } = render(
<VacationDaysInput label="Vacation" value={30} onChange={() => {}} />
)
const input = getByTestId('vacation-days-field') as HTMLInputElement
expect(input.value).toBe('30')
})
it('calls onChange with new value', () => {
const onChange = vi.fn()
const { getByTestId } = render(
<VacationDaysInput label="Vacation" value={25} onChange={onChange} />
)
const input = getByTestId('vacation-days-field')
fireEvent.change(input, { target: { value: '30' } })
expect(onChange).toHaveBeenCalledWith(30)
})
it('enforces min constraint', () => {
const onChange = vi.fn()
const { getByTestId } = render(
<VacationDaysInput
label="Vacation"
value={10}
onChange={onChange}
min={5}
/>
)
const input = getByTestId('vacation-days-field')
fireEvent.change(input, { target: { value: '2' } })
expect(onChange).toHaveBeenCalledWith(5) // min enforced
})
it('enforces max constraint', () => {
const onChange = vi.fn()
const { getByTestId } = render(
<VacationDaysInput
label="Vacation"
value={25}
onChange={onChange}
max={50}
/>
)
const input = getByTestId('vacation-days-field')
fireEvent.change(input, { target: { value: '100' } })
expect(onChange).toHaveBeenCalledWith(50) // max enforced
})
it('displays default range', () => {
const { getByTestId } = render(
<VacationDaysInput label="Vacation" value={25} onChange={() => {}} />
)
expect(getByTestId('vacation-days-input').textContent).toContain('0 – 365')
})
it('displays custom range', () => {
const { getByTestId } = render(
<VacationDaysInput label="Vacation" value={25} onChange={() => {}} min={10} max={50} />
)
expect(getByTestId('vacation-days-input').textContent).toContain('10 – 50')
})
it('handles non-numeric input gracefully', () => {
const onChange = vi.fn()
const { getByTestId } = render(
<VacationDaysInput label="Vacation" value={25} onChange={onChange} />
)
const input = getByTestId('vacation-days-field')
fireEvent.change(input, { target: { value: 'abc' } })
expect(onChange).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,45 @@
import { ReactNode } from 'react'
export interface VacationDaysInputProps {
label: string
value: number
onChange: (val: number) => void
min?: number
max?: number
}
export function VacationDaysInput({
label,
value,
onChange,
min = 0,
max = 365,
}: VacationDaysInputProps): ReactNode {
return (
<div data-testid="vacation-days-input" className="vacation-days-input">
<label htmlFor="vacation-days-field" className="vacation-days-input__label">
{label}
</label>
<input
id="vacation-days-field"
type="number"
min={min}
max={max}
value={value}
onChange={(e) => {
const num = parseInt(e.target.value, 10)
if (!isNaN(num)) {
onChange(Math.max(min, Math.min(num, max)))
}
}}
data-testid="vacation-days-field"
className="vacation-days-input__field"
/>
<div className="vacation-days-input__range">
<small>
{min} – {max}
</small>
</div>
</div>
)
}