import { ReactNode, useEffect, useMemo, useRef, useState } from 'react' import { useWorkspace, usePlanSyncContextOptional } from '@/context' import { useVacationDays, useShowWeekNumbers, useChildSettings, useDayOverrides, useCalendarDays, useWorkspaceMembers, usePreschoolPlanningDays, useDebouncedCallback, useAnalytics, } from '@/hooks' import * as api from '@/services/api' import { isApiError } from '@/services/errors' import { useTranslation, type Locale, type TranslationKey } from '@/i18n' import { Layout } from '@/components/layout/Layout' import { PageSkeleton } from '@/components/common/PageSkeleton' import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput' import { ParentProfileInput, type SalaryType, type EmploymentStatus } from '@/components/inputs/ParentProfileInput' import { VacationDaysInput } from '@/components/inputs/VacationDaysInput' import { VacationDaysTable, type VacationDaysTableRow } from '@/components/vacation-days/VacationDaysTable' import { CalendarExceptions } from '@/components/calendar/CalendarExceptions' import { BalanceFlow } from '@/components/balance/BalanceFlow' import { PartnerInvitePanel } from '@/components/settings/PartnerInvitePanel' import { Alert } from '@/components/common/Alert' import { InlineConfirm } from '@/components/common/InlineConfirm' import { orderSelfFirst } from '@/utils/members' import type { VacationDays } from '@/types' import './settings.css' const RED_DAYS_WINDOW_YEARS = 4 const DEFAULT_VACATION_DAYS = 25 function isSalaryType(value: string): value is SalaryType { return value === 'monthly' || value === 'hourly' } function isEmploymentStatus(value: string): value is EmploymentStatus { return value === 'employed' || value === 'jobseeking' || value === 'none' } // Backend `message_key`s for the parent add/rename/remove endpoints (see // workspace_service.py) mapped to real translation keys — same idiom as // `PlanResults.tsx`'s `WARNING_BODY_KEYS` etc. A key not present here is // intentionally never shown raw to the user (§ "Render the refusal cases // properly") — callers fall back to a generic message instead. const PARTNER_ERROR_KEYS: Record = { 'workspace.notFound': 'settings.partnerActionErrorNotFound', 'workspace.parents.notFound': 'settings.partnerActionErrorNotFound', 'workspace.parents.invalidName': 'settings.partnerNameInvalid', 'workspace.parents.remove.self': 'settings.removePartnerErrorSelf', 'workspace.parents.remove.hasLogin': 'settings.removePartnerErrorHasLogin', 'workspace.parents.add.maxReached': 'settings.partnerActionErrorMaxReached', } // `hasDependentData`'s `data.blockers` is a subset of these four kinds — // each gets its own i18n label so the refusal message can name what's // actually blocking removal instead of a generic "can't remove". const BLOCKER_KEYS: Record = { leave_wishes: 'settings.removeBlockerLeaveWishes', planned_leave: 'settings.removeBlockerPlannedLeave', parental_leave: 'settings.removeBlockerParentalLeave', vacation_days: 'settings.removeBlockerVacationDays', } function isBlockerList(value: unknown): value is string[] { return Array.isArray(value) && value.every((v) => typeof v === 'string') } /** Turns a caught error from an add/rename/remove-partner call into a safe, * translated message. Never surfaces a raw `message_key` — an unrecognised * structured error falls back to `fallback`, same as a non-API error would. */ function describePartnerError(err: unknown, t: (key: TranslationKey, params?: Record) => string, fallback: string): string { if (isApiError(err)) { const details = err.error.details as { messageKey?: string; data?: Record } | undefined const messageKey = details?.messageKey if (messageKey === 'workspace.parents.remove.hasDependentData') { const blockers = isBlockerList(details?.data?.blockers) ? details.data.blockers : [] const blockerLabels = blockers .map((b) => (BLOCKER_KEYS[b] ? t(BLOCKER_KEYS[b]) : b)) .join(', ') return t('settings.removePartnerErrorHasDependentData', { blockers: blockerLabels }) } if (messageKey && PARTNER_ERROR_KEYS[messageKey]) { return t(PARTNER_ERROR_KEYS[messageKey]) } // A structured error we don't recognise — never show the raw key. if (messageKey) return fallback } return err instanceof Error ? err.message : fallback } /** * The child's birth year (or the current year, if earlier/no birth date) * through — at minimum — the year the child turns 4. * * Starting at the birth year, not `currentYear`, lets Settings offer an * editable vacation-days row for every past year too — see * `AnalyticsService._vacation_year_series`'s `current_year` rule, which * stops assuming the full statutory default for a past year with no row * precisely so a real figure can be entered here instead. */ function computeVacationYearRange(birthDate: string | undefined, currentYear: number): number[] { let startYear = currentYear let endYear = currentYear if (birthDate) { const birth = new Date(birthDate) if (!isNaN(birth.getTime())) { startYear = Math.min(startYear, birth.getFullYear()) const fourthBirthday = new Date(birthDate) fourthBirthday.setFullYear(fourthBirthday.getFullYear() + 4) endYear = Math.max(endYear, fourthBirthday.getFullYear()) } } const years: number[] = [] for (let year = startYear; year <= endYear; year++) { years.push(year) } return years } const PRESCHOOL_AUTOSAVE_DELAY_MS = 800 // How long the recompute button (Kalender & språk panel) waits for // `PlanSyncProvider`'s triggered sync to land before giving up and // reporting a failure — a sync that errors instead of resolving is only // ever logged (see `PlanSyncProvider.tsx`), so without a ceiling here the // button would spin forever on that path. const RECOMPUTE_TIMEOUT_MS = 10_000 /** * One (child, year) row letting the user record the true number of * preschool planning days actually taken that year — the child-level * `preschoolPlanningDaysPerYear` field above is only ever an estimate used * as this row's default until a real number is entered (§ user complaint * "how many did you take"). Its own component so each row can hold its own * `useDebouncedCallback` instance, autosaving independently per field/year — * same reasoning as `VacationYearRow` in `VacationDaysTable.tsx`. */ function PreschoolPlanningYearRow({ childId, year, defaultPlannedDays, }: { childId: string year: number defaultPlannedDays: number }): ReactNode { const { t } = useTranslation() const { data, setPlanned, recordTaken } = usePreschoolPlanningDays(childId, year) const [plannedDraft, setPlannedDraft] = useState(data?.plannedDays ?? defaultPlannedDays) // `''` represents "not recorded" (`takenDays === null`) — deliberately // distinct from `0` ("recorded, none taken"), so the input starts empty // rather than defaulting to zero. const [takenDraft, setTakenDraft] = useState(data?.takenDays === null || data?.takenDays === undefined ? '' : String(data.takenDays)) const plannedDirtyRef = useRef(false) const takenDirtyRef = useRef(false) useEffect(() => { if (plannedDirtyRef.current) return setPlannedDraft(data?.plannedDays ?? defaultPlannedDays) }, [data?.plannedDays, defaultPlannedDays]) useEffect(() => { if (takenDirtyRef.current) return setTakenDraft(data?.takenDays === null || data?.takenDays === undefined ? '' : String(data.takenDays)) }, [data?.takenDays]) const debouncedSetPlanned = useDebouncedCallback((value: number) => { void setPlanned(value) .then(() => { plannedDirtyRef.current = false }) .catch(() => { // Leave `plannedDirtyRef` set so a stale server value can't creep // back in over the value still shown on screen. }) }, PRESCHOOL_AUTOSAVE_DELAY_MS) const debouncedRecordTaken = useDebouncedCallback((value: number) => { void recordTaken(value) .then(() => { takenDirtyRef.current = false }) .catch(() => {}) }, PRESCHOOL_AUTOSAVE_DELAY_MS) return (
{year} {data?.isDefault && ( {t('vacationDaysTable.assumedBadge')} )}
) } export function Settings(): ReactNode { const { t, explicitLocale, setLocale } = useTranslation() const { workspace, isLoading: wsLoading, selectedChildId, children, personalDataUuid, refreshContext, bumpPlanVersion } = useWorkspace() const { data: vacDays, isLoading: vacLoading, upsert: upsertVacationDays } = useVacationDays(workspace?.id ?? '') const { data: members, isLoading: membersLoading, refresh: refreshMembers, } = useWorkspaceMembers(workspace?.id ?? null) const { data: childDetails, isLoading: childDetailsLoading, update: updateChildDetails, } = useChildSettings(selectedChildId) const { data: dayOverrides, isLoading: dayOverridesLoading, upsert: upsertDayOverride, remove: removeDayOverride, } = useDayOverrides(workspace?.id ?? '') const [showWeekNumbers, setShowWeekNumbers] = useShowWeekNumbers() const [redDaysStartYear, setRedDaysStartYear] = useState(() => new Date().getFullYear()) const { data: calendarWindow, isLoading: calendarLoading, error: calendarError, } = useCalendarDays(workspace?.id ?? '', redDaysStartYear, RED_DAYS_WINDOW_YEARS) const [childBirthDate, setChildBirthDate] = useState('') const [preschoolPlanningDays, setPreschoolPlanningDays] = useState(6) const [parentData, setParentData] = useState<{ salaryType?: SalaryType; employmentStatus?: EmploymentStatus }>({}) const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) // Each panel owns exactly one `isSaving` flag, scoped to its own actions — // previously a single page-wide `isSaving` meant saving a vacation year // (Saldon panel) also disabled the "Spara" button in the family panel, // and vice versa. const [isSavingFamily, setIsSavingFamily] = useState(false) const [isSavingRedDayPref, setIsSavingRedDayPref] = useState(false) const [isRecomputing, setIsRecomputing] = useState(false) const [newPartnerName, setNewPartnerName] = useState('') const [renamingUuid, setRenamingUuid] = useState(null) const [renameValue, setRenameValue] = useState('') const [removeConfirmUuid, setRemoveConfirmUuid] = useState(null) const [memberActionError, setMemberActionError] = useState<{ uuid: string; text: string } | null>(null) // Optional: in the real app `Settings` is always mounted inside // `WorkspaceProvider` (and therefore `PlanSyncProvider`), but this page's // own test suite renders it with `@/context` auto-mocked wholesale — the // optional variant keeps the recompute button below testable without // requiring a real `PlanSyncProvider` in every existing test. See // `PlanSyncProvider.tsx`. const planSyncContext = usePlanSyncContextOptional() const latestProposalVersion = planSyncContext?.latestProposalVersion ?? 0 // The `latestProposalVersion` the recompute button's own triggered sync // should land past — `null` while no recompute is in flight, so a sync // started by something else entirely (e.g. a vacation-day autosave // elsewhere on this same page) never flips this button's spinner off. const recomputeBaselineRef = useRef(null) const recomputeTimeoutRef = useRef | null>(null) // Detects the recompute button's own triggered sync landing. // `bumpPlanVersion()` (in `handleRecompute` below) doesn't itself return // anything to await — the sync it causes runs inside `PlanSyncProvider`, // entirely outside this component. `latestProposalVersion` moving past // the baseline captured when the button was clicked is this component's // only signal that *that* sync (as opposed to some unrelated one) has // completed. A `setTimeout` fallback covers a sync that errors instead of // resolving (`PlanSyncProvider` only logs that case, see its doc comment) // — without it a failed sync would leave this button spinning forever. useEffect(() => { if (recomputeBaselineRef.current === null) return if (latestProposalVersion <= recomputeBaselineRef.current) return recomputeBaselineRef.current = null if (recomputeTimeoutRef.current !== null) { clearTimeout(recomputeTimeoutRef.current) recomputeTimeoutRef.current = null } setIsRecomputing(false) setSaveMessage({ type: 'success', text: t('settings.recomputeSuccess') }) setTimeout(() => setSaveMessage(null), 3000) }, [latestProposalVersion, t]) // Settings isn't scoped to a single child the way Onboarding/Dashboard are // — the balance flow needs somewhere to attach a saved balance even before // a child is chosen/created elsewhere in the app. const balanceChildId = selectedChildId ?? children[0]?.id ?? null // Supplies the vacation-days table's "at risk" column (already computed by // the same analytics endpoint Dashboard.tsx uses — see // `AnalyticsService._vacation_year_series`). Deliberately not folded into // `isLoading` below: it's an annotation on the editable table, not data // the table needs to render at all, and a new/unselected-child workspace // (no `balanceChildId` yet) must not block the rest of the page on it. const { data: analytics } = useAnalytics(workspace?.id ?? null, balanceChildId ?? undefined) const isLoading = wsLoading || vacLoading || childDetailsLoading || dayOverridesLoading useEffect(() => { if (childDetails) { setChildBirthDate(childDetails.birthDate) setPreschoolPlanningDays(childDetails.preschoolPlanningDaysPerYear) } }, [childDetails]) // `#saldon` in the URL scrolls that panel into view once the page's // content has actually mounted. useEffect(() => { if (isLoading) return if (window.location.hash === '#saldon') { document.getElementById('saldon')?.scrollIntoView() } }, [isLoading]) const selectedChild = children.find((c) => c.id === selectedChildId) const hasPartner = members.some((m) => m.uuid !== personalDataUuid) // Self first, matching `BalanceFlow`'s ordering for the same list of // members — used to render one `VacationDaysTable` per member (§3.3). const orderedMembers = orderSelfFirst(members, personalDataUuid) const currentYear = new Date().getFullYear() const vacationYears = useMemo( () => computeVacationYearRange(selectedChild?.birth_date, currentYear), [selectedChild?.birth_date, currentYear], ) // Preschool planning-day rows only ever make sense current-year forward // (§Task 1 preschool reserve is a forecast, not a historical record) — // `vacationYears` above was widened back to the child's birth year for // the vacation-days table only, so this block gets its own slice rather // than sharing it. const preschoolYears = useMemo( () => vacationYears.filter((year) => year >= currentYear), [vacationYears, currentYear], ) // Keyed on (personalDataUuid, year), NOT year alone — with more than one // workspace member, matching on year alone would silently pick up // whichever member's row for that year happened to come first in // `vacDays`, showing (and letting you overwrite) the wrong parent's data. const vacationRowsByMember: VacationDaysTableRow[][] = useMemo( () => orderedMembers.map((member) => { const projection = analytics?.parentProjections.find((p) => p.personalDataUuid === member.uuid) return vacationYears.map((year) => { const existing = vacDays.find((v: VacationDays) => v.personalDataUuid === member.uuid && v.year === year) // Analytics may not have loaded yet (or there's no child selected) // — the at-risk figures then default to 0 rather than block the // rest of this editable row on a supplementary fetch. const vacationBalance = projection?.yearBalances.find((b) => b.year === year)?.vacation // No row entered: fall back to the SAME figure analytics assumed // for this year (`AnalyticsService._vacation_year_series`'s // `earned` — statutory default for a current/future year, the // floor for a past one) rather than re-deriving that rule here. // Only falls back further to the statutory default when analytics // hasn't loaded yet, matching the pre-existing no-data behaviour. const fallbackTotalDays = vacationBalance?.earned ?? DEFAULT_VACATION_DAYS return { year, totalDays: existing?.totalDays ?? fallbackTotalDays, savedDaysIn: existing?.savedDaysIn ?? 0, // §3.2 — no vacation_days row for this year yet: an assumed // default is shown, not an entered value. Mirrors the backend's // `VacationBalanceResponse.is_assumed` for the propose-time table. isAssumed: existing === undefined, isPast: year < currentYear, expiring: vacationBalance?.expiring ?? 0, expiringAge: vacationBalance?.expiringAge ?? 0, expiringOverCap: vacationBalance?.expiringOverCap ?? 0, } }) }), [orderedMembers, vacationYears, vacDays, analytics, currentYear], ) if (isLoading) { return ( ) } const handleLanguageChange = (value: string) => { setLocale(value === 'auto' ? null : (value as Locale)) } const showMessage = (type: 'success' | 'error', text: string) => { setSaveMessage({ type, text }) setTimeout(() => setSaveMessage(null), 3000) } // One combined save for the "Barn & familj" panel's editable fields (child // birth date/preschool days, own salary/employment) — one button, one // `isSavingFamily` flag, matching the rest of that panel's actions below. const handleSaveFamily = async () => { try { setIsSavingFamily(true) if (selectedChildId && childBirthDate) { await updateChildDetails({ birthDate: childBirthDate, preschoolPlanningDaysPerYear: preschoolPlanningDays, }) } if (personalDataUuid) { await api.updateParentSettings(personalDataUuid, { salary_type: parentData.salaryType, employment_status: parentData.employmentStatus, }) // Salary type feeds the allocator's cost calculations (sjukpenning // vs lägstanivå tradeoffs) — re-type the rest of the plan's // unpinned days. See `WorkspaceContext.planVersion`. (Child-details // saves above already bump via `useChildSettings.update`.) bumpPlanVersion() } showMessage('success', t('settings.saveFamilySuccess')) } catch (err) { showMessage('error', err instanceof Error ? err.message : t('settings.saveFamilyError')) } finally { setIsSavingFamily(false) } } const handleParentChange = (field: string, val: string) => { setParentData((prev) => { const next = { ...prev } if (field === 'salaryType') { if (isSalaryType(val)) { next.salaryType = val } else { delete next.salaryType } } else if (field === 'employmentStatus') { if (isEmploymentStatus(val)) { next.employmentStatus = val } else { delete next.employmentStatus } } return next }) } // Takes the owning member's uuid explicitly (§3.3) rather than always // saving under the logged-in user's own `personalDataUuid` — each // `VacationDaysTable` instance below binds this to its own member. // Deliberately doesn't catch: `VacationDaysTable`'s row calls this from // its own autosave and shows a per-row saving/saved/error indicator from // the settled promise, so a page-wide toast here would be redundant (and // noisy — several rows across several members can be autosaving at once). const handleSaveVacationYear = async ( memberUuid: string, year: number, totalDays: number, savedDaysIn: number, ): Promise => { if (!workspace?.id) return await upsertVacationDays({ personalDataUuid: memberUuid, workspaceId: workspace.id, year, totalDays, savedDaysIn, }) } const handleRedDaysPrevWindow = () => { setRedDaysStartYear((y) => y - RED_DAYS_WINDOW_YEARS) } const handleRedDaysNextWindow = () => { setRedDaysStartYear((y) => y + RED_DAYS_WINDOW_YEARS) } const handleAddPartner = async () => { if (!newPartnerName.trim() || !workspace?.id) { showMessage('error', t('settings.partnerNameRequired')) return } try { setIsSavingFamily(true) setSaveMessage(null) await api.addPartner(workspace.id, newPartnerName) await refreshMembers() // A new partner changes which parent days the allocator can assign // leave to — re-type the rest of the plan's unpinned days. See // `WorkspaceContext.planVersion`. bumpPlanVersion() setNewPartnerName('') showMessage('success', t('settings.addPartnerSuccess')) } catch (err) { showMessage('error', describePartnerError(err, t, t('settings.addPartnerError'))) } finally { setIsSavingFamily(false) } } const handleStartRename = (member: { uuid: string; name: string }) => { setMemberActionError(null) setRemoveConfirmUuid(null) setRenamingUuid(member.uuid) setRenameValue(member.name) } const handleCancelRename = () => { setRenamingUuid(null) setRenameValue('') } const handleSaveRename = async (uuid: string) => { if (!workspace?.id || !renameValue.trim()) { setMemberActionError({ uuid, text: t('settings.partnerNameRequired') }) return } try { setIsSavingFamily(true) setMemberActionError(null) await api.renameWorkspaceParent(workspace.id, uuid, renameValue) await refreshMembers() if (uuid === personalDataUuid) { await refreshContext() } // No `bumpPlanVersion()` here, unlike add/remove below — a rename is // cosmetic and doesn't change which days the allocator can assign to // which parent, so there's nothing for a re-plan to fix. setRenamingUuid(null) setRenameValue('') } catch (err) { setMemberActionError({ uuid, text: describePartnerError(err, t, t('settings.renamePartnerError')) }) } finally { setIsSavingFamily(false) } } const handleRequestRemove = (uuid: string) => { setMemberActionError(null) setRenamingUuid(null) setRemoveConfirmUuid(uuid) } const handleCancelRemove = () => { setRemoveConfirmUuid(null) } const handleConfirmRemove = async (uuid: string) => { if (!workspace?.id) return try { setIsSavingFamily(true) setMemberActionError(null) await api.removeWorkspaceParent(workspace.id, uuid) setRemoveConfirmUuid(null) await refreshMembers() // Removing a partner changes which parent days the allocator can // assign leave to — re-type the rest of the plan's unpinned days. // See `WorkspaceContext.planVersion`. bumpPlanVersion() } catch (err) { setMemberActionError({ uuid, text: describePartnerError(err, t, t('settings.removePartnerError')) }) } finally { setIsSavingFamily(false) } } const handleToggleTakePlOnRedDays = async (checked: boolean) => { if (!workspace?.id) return try { setIsSavingRedDayPref(true) await api.updateWorkspace(workspace.id, { takePlOnRedDays: checked }) await refreshContext() // Whether PL may be taken on red days changes which dates the // allocator can place leave on — re-type the rest of the plan's // unpinned days. See `WorkspaceContext.planVersion`. bumpPlanVersion() } catch (err) { showMessage('error', err instanceof Error ? err.message : t('settings.takePlOnRedDaysError')) } finally { setIsSavingRedDayPref(false) } } // Manual recompute: bumping `planVersion` is the whole implementation — // `PlanSyncProvider` (mounted app-wide) turns the bump into a // `POST /api/plan/sync` itself. Calling `api.syncPlan` directly here // instead would bypass that provider's burst-coalescing. `sync_plan` // always writes now (displacement is informational, never a refusal — // see `PlanChange.evicted`), so the effect above only ever reports // success once the triggered sync's proposal version lands. const handleRecompute = async () => { recomputeBaselineRef.current = latestProposalVersion setIsRecomputing(true) setSaveMessage(null) if (recomputeTimeoutRef.current !== null) { clearTimeout(recomputeTimeoutRef.current) } recomputeTimeoutRef.current = setTimeout(() => { recomputeBaselineRef.current = null recomputeTimeoutRef.current = null setIsRecomputing(false) showMessage('error', t('settings.recomputeTimeout')) }, RECOMPUTE_TIMEOUT_MS) bumpPlanVersion() } return (

{t('settings.title')}

{saveMessage && ( {saveMessage.text} )}

{t('settings.familySectionTitle')}

{selectedChild ? (

{t('settings.selectedChildLabel')} {selectedChild.name}

{t('settings.preschoolHelp')}

{selectedChildId && (

{t('settings.preschoolPerYearTitle')}

{t('settings.preschoolPerYearHelp')}

{preschoolYears.map((year) => ( ))}
)}
) : (

{t('settings.noChildSelected')}

)}

{t('settings.profileSectionTitle')}

{t('settings.profileHelp')}

{t('settings.partnerSectionTitle')}

{/* A member added via "Lägg till partner" (or waiting on an invite) * is a placeholder with no login of their own — `partnerLinkedIntro` * used to claim an account link that doesn't exist for that case. * `invite.partnerPlaceholderIntro` describes what's actually true: * the slot is reserved, an invite connects it to a real account. */}

{hasPartner ? t('invite.partnerPlaceholderIntro') : t('settings.partnerIntro')}

{membersLoading ? (

{t('settings.membersLoading')}

) : (
    {members.map((member) => { const isSelf = member.uuid === personalDataUuid const isRenaming = renamingUuid === member.uuid const isConfirmingRemove = removeConfirmUuid === member.uuid return (
  • {isRenaming ? ( <> setRenameValue(e.target.value)} disabled={isSavingFamily} className="field__control settings-panel__partner-input" data-testid={`rename-input-${member.uuid}`} /> ) : ( <> {member.name} {isSelf && ( {t('settings.youBadge')} )} {!isSelf && (isConfirmingRemove ? ( handleConfirmRemove(member.uuid)} onCancel={handleCancelRemove} isBusy={isSavingFamily} confirmTestId={`remove-confirm-${member.uuid}`} cancelTestId={`remove-cancel-${member.uuid}`} confirmFirst small /> ) : ( ))} )} {memberActionError?.uuid === member.uuid && ( {memberActionError.text} )}
  • ) })}
)} {!hasPartner && (
setNewPartnerName(e.target.value)} disabled={isSavingFamily} className="field__control settings-panel__partner-input" />
)}

{t('settings.balanceSectionTitle')}

{membersLoading ? (

{t('settings.membersLoading')}

) : ( void refreshMembers()} /> )}

{t('settings.vacationHelp')}

{!membersLoading && orderedMembers.map((member, index) => (
{orderedMembers.length > 1 && (

{member.name} {member.uuid === personalDataUuid && ( {t('settings.youBadge')} )}

)} handleSaveVacationYear(member.uuid, year, totalDays, savedDaysIn) } personalDataUuid={member.uuid} />
))}

{t('settings.calendarLanguageSectionTitle')}

{t('settings.takePlOnRedDaysHelp')}

{t('settings.recomputeHelp')}

{t('settings.exceptionsSectionTitle')}

) }