From e9d7cee66b931bbf689b368e5adf6e40dfa273fb Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Tue, 22 Sep 2026 22:02:09 +0200 Subject: [PATCH 1/5] Pull form fields and alert banners into shared components FormField owns the .field markup and wires id/aria-invalid/aria-describedby onto its control; Alert owns role=alert banners. Auth, Join and the partner invite panel drop their one-off error classes for the shared alert style. VacationDaysInput and ParentProfileInput move onto the .field primitives. Co-Authored-By: Claude Opus 5.5 --- .../src/components/balance/BalanceFlow.tsx | 7 +- .../src/components/calendar/PeriodPopover.tsx | 40 +++------ frontend/src/components/common/Alert.test.tsx | 41 +++++++++ frontend/src/components/common/Alert.tsx | 26 ++++++ .../src/components/common/FormField.test.tsx | 84 +++++++++++++++++++ frontend/src/components/common/FormField.tsx | 73 ++++++++++++++++ .../components/inputs/ParentProfileInput.tsx | 12 +-- .../components/inputs/VacationDaysInput.tsx | 14 ++-- .../components/modals/ParentBalanceForm.tsx | 84 +++++-------------- .../src/components/modals/PlannedDayModal.tsx | 32 ++----- .../src/components/modals/QRCodeModal.tsx | 5 +- .../settings/PartnerInvitePanel.tsx | 17 +--- .../settings/partner-invite-panel.css | 6 -- frontend/src/pages/Auth.tsx | 32 ++----- frontend/src/pages/Dashboard.tsx | 9 +- frontend/src/pages/Join.tsx | 36 +++----- frontend/src/pages/Onboarding.tsx | 40 +++------ frontend/src/pages/Settings.tsx | 17 ++-- frontend/src/pages/auth.css | 6 -- frontend/src/pages/join.css | 7 -- frontend/src/styles/components.css | 37 ++++---- 21 files changed, 348 insertions(+), 277 deletions(-) create mode 100644 frontend/src/components/common/Alert.test.tsx create mode 100644 frontend/src/components/common/Alert.tsx create mode 100644 frontend/src/components/common/FormField.test.tsx create mode 100644 frontend/src/components/common/FormField.tsx diff --git a/frontend/src/components/balance/BalanceFlow.tsx b/frontend/src/components/balance/BalanceFlow.tsx index d587db2..b678621 100644 --- a/frontend/src/components/balance/BalanceFlow.tsx +++ b/frontend/src/components/balance/BalanceFlow.tsx @@ -14,6 +14,7 @@ import type { PersonalData } from '@/types' import { QRCodeModal } from '@/components/modals/QRCodeModal' import { ParentBalanceForm, type ParentBalanceInitial } from '@/components/modals/ParentBalanceForm' import { LoadingSpinner } from '@/components/common/LoadingSpinner' +import { Alert } from '@/components/common/Alert' import './balance-flow.css' export interface BalanceFlowProps { @@ -325,11 +326,7 @@ export function BalanceFlow({ disabled={isAddingDeducedPartner} /> - {deducedPartnerError && ( -
- {deducedPartnerError} -
- )} + {deducedPartnerError && {deducedPartnerError}}
diff --git a/frontend/src/components/modals/PlannedDayModal.tsx b/frontend/src/components/modals/PlannedDayModal.tsx index 724e7e1..5b658e3 100644 --- a/frontend/src/components/modals/PlannedDayModal.tsx +++ b/frontend/src/components/modals/PlannedDayModal.tsx @@ -2,6 +2,8 @@ import { ReactNode, useEffect, useState } from 'react' import type { PlannedLeaveDay } from '@/types' import { useTranslation } from '@/i18n' import { Icon } from '@/components/common/Icon' +import { FormField } from '@/components/common/FormField' +import { Alert } from '@/components/common/Alert' import './planned-day-modal.css' /** The default path — todo.md #10/#11: the planner decides which day type @@ -283,11 +285,7 @@ export function PlannedDayModal({
{!isRangeMode &&

{displayDate}

} - {(error || localError) && ( -
- {error || localError} -
- )} + {(error || localError) && {error || localError}} {!isRangeMode && excludedEntries.map((entry) => ( @@ -313,12 +311,8 @@ export function PlannedDayModal({
{!forcedAdvanced && hasMultipleParents && ( -
- + -
+ )} {!forcedAdvanced && hasMultipleParents && ( @@ -382,12 +376,8 @@ export function PlannedDayModal({ {showPinnedControls && (
-
- + -
+ {freeDaysWillStayOff && (

@@ -419,12 +409,8 @@ export function PlannedDayModal({ )} {hasMultipleParents && ( -

- + -
+ )} {dayType !== 'auto' && ( diff --git a/frontend/src/components/modals/QRCodeModal.tsx b/frontend/src/components/modals/QRCodeModal.tsx index 0909418..d08dfe2 100644 --- a/frontend/src/components/modals/QRCodeModal.tsx +++ b/frontend/src/components/modals/QRCodeModal.tsx @@ -2,6 +2,7 @@ import { ReactNode } from 'react' import type { ScraperStatus } from '@/hooks/useScraperFetch' import { useTranslation, type TranslationKey } from '@/i18n' import { Icon } from '@/components/common/Icon' +import { Alert } from '@/components/common/Alert' import './qrcode-modal.css' // Every `ScraperStatus` maps to a translated `balance.status.*` label — the @@ -113,9 +114,9 @@ export function QRCodeModal({ )} {error && ( -
+ {t('qrModal.errorLabel')} {error} -
+ )}
diff --git a/frontend/src/components/settings/PartnerInvitePanel.tsx b/frontend/src/components/settings/PartnerInvitePanel.tsx index fefe68c..57b234c 100644 --- a/frontend/src/components/settings/PartnerInvitePanel.tsx +++ b/frontend/src/components/settings/PartnerInvitePanel.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react' import * as api from '@/services/api' import { useTranslation } from '@/i18n' import type { Invitation } from '@/types' +import { Alert } from '@/components/common/Alert' import './partner-invite-panel.css' export interface PartnerInvitePanelProps { @@ -118,11 +119,7 @@ export function PartnerInvitePanel({ workspaceId }: PartnerInvitePanelProps) { {isCreating ? t('invite.generatingButton') : t('invite.generateButton')} - {createError && ( -

- {createError} -

- )} + {createError && {createError}} {inviteLink && (
@@ -157,9 +154,7 @@ export function PartnerInvitePanel({ workspaceId }: PartnerInvitePanelProps) { {isListLoading ? (

{t('common.loading')}

) : listError ? ( -

- {listError} -

+ {listError} ) : pending.length === 0 ? (

{t('invite.noPending')}

) : ( @@ -183,11 +178,7 @@ export function PartnerInvitePanel({ workspaceId }: PartnerInvitePanelProps) { )} - {revokeError && ( -

- {revokeError} -

- )} + {revokeError && {revokeError}}
) } diff --git a/frontend/src/components/settings/partner-invite-panel.css b/frontend/src/components/settings/partner-invite-panel.css index f1c2e11..ff48506 100644 --- a/frontend/src/components/settings/partner-invite-panel.css +++ b/frontend/src/components/settings/partner-invite-panel.css @@ -75,9 +75,3 @@ font-size: var(--fs-sm); color: var(--c-text-muted); } - -.partner-invite-panel__error { - margin: 0; - font-size: var(--fs-sm); - color: var(--c-danger); -} diff --git a/frontend/src/pages/Auth.tsx b/frontend/src/pages/Auth.tsx index 09939fb..1ecdbbe 100644 --- a/frontend/src/pages/Auth.tsx +++ b/frontend/src/pages/Auth.tsx @@ -4,6 +4,8 @@ import { useAuth } from '@/context/AuthContext' import { useWorkspace } from '@/context/WorkspaceContext' import { useTranslation } from '@/i18n' import { LanguageToggle } from '@/components/layout/LanguageToggle' +import { FormField } from '@/components/common/FormField' +import { Alert } from '@/components/common/Alert' import './auth.css' export function Auth() { @@ -77,25 +79,17 @@ export function Auth() {
void handleSubmit(e)}> {isRegister && ( -
- + setDisplayName(e.target.value)} /> -
+ )} -
- + setEmail(e.target.value)} /> -
-
- + + setPassword(e.target.value)} /> -
- {error && ( -

- {error} -

- )} + + {error && {error}} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 44a2690..7341df5 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -29,6 +29,7 @@ import { VacationRescueTip } from '@/components/dashboard/VacationRescueTip' import { ParentalLeaveBreakdown } from '@/components/dashboard/ParentalLeaveBreakdown' import { TipCard } from '@/components/inputs/TipCard' import { Onboarding } from '@/pages/Onboarding' +import { Alert } from '@/components/common/Alert' import type { RescueDay, Tip } from '@/types' /** Key matching `rescueDayGroups`' own `Map` key below — shared so a @@ -332,9 +333,9 @@ export function Dashboard(): ReactNode {
{analyticsError && ( -
+ {analyticsError} -
+ )} {analytics && selectedChild && ( @@ -371,9 +372,9 @@ export function Dashboard(): ReactNode {

{t('dashboard.leaveOverviewTitle')}

{age4Bars.length > 0 && } {proposalError && ( -
+ {proposalError} -
+ )} -

+ {errorKey ? t(errorKey) : t('invite.joinGenericError')} -

+ {isAuthenticated && (
- {familyError && ( -
- {familyError} -
- )} + {familyError && {familyError}}
+ ) + + const cancelButton = ( + + ) + + return ( +
+ {prompt} + {confirmFirst ? ( + <> + {confirmButton} + {cancelButton} + + ) : ( + <> + {cancelButton} + {confirmButton} + + )} +
+ ) +} diff --git a/frontend/src/components/common/Modal.test.tsx b/frontend/src/components/common/Modal.test.tsx new file mode 100644 index 0000000..6333b57 --- /dev/null +++ b/frontend/src/components/common/Modal.test.tsx @@ -0,0 +1,184 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Modal } from './Modal' + +describe('Modal', () => { + it('renders the title, close button and children', () => { + render( + +

Body content

+
, + ) + expect(screen.getByRole('heading', { name: 'Hello' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument() + expect(screen.getByText('Body content')).toBeInTheDocument() + }) + + it('defaults to the md content size', () => { + const { container } = render( + +

Body

+
, + ) + expect(container.querySelector('.modal__content--md')).not.toBeNull() + }) + + it('applies the sm content size when requested', () => { + const { container } = render( + +

Body

+
, + ) + expect(container.querySelector('.modal__content--sm')).not.toBeNull() + }) + + it('calls onClose when the close button is clicked', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + render( + +

Body

+
, + ) + await user.click(screen.getByRole('button', { name: 'Close' })) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('disables the close button when closeDisabled is set', () => { + render( + +

Body

+
, + ) + expect(screen.getByRole('button', { name: 'Close' })).toBeDisabled() + }) + + it('closes on overlay click by default', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + const { container } = render( + +

Body

+
, + ) + await user.click(container.querySelector('.modal__overlay') as Element) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('does not close on overlay click when closeOnOverlayClick is false', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + const { container } = render( + +

Body

+
, + ) + await user.click(container.querySelector('.modal__overlay') as Element) + expect(onClose).not.toHaveBeenCalled() + }) + + it('does not close when clicking inside the content box', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + render( + +

Body content

+
, + ) + await user.click(screen.getByText('Body content')) + expect(onClose).not.toHaveBeenCalled() + }) + + it('calls onClose on Escape', () => { + const onClose = vi.fn() + render( + +

Body

+
, + ) + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('ignores other keys', () => { + const onClose = vi.fn() + render( + +

Body

+
, + ) + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })) + expect(onClose).not.toHaveBeenCalled() + }) + + it('does not call onClose on Escape when closeDisabled is set', () => { + const onClose = vi.fn() + render( + +

Body

+
, + ) + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + expect(onClose).not.toHaveBeenCalled() + }) + + it('removes the Escape listener on unmount', () => { + const onClose = vi.fn() + const { unmount } = render( + +

Body

+
, + ) + unmount() + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + expect(onClose).not.toHaveBeenCalled() + }) + + it('renders a footer when provided', () => { + render( + Save}> +

Body

+
, + ) + expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument() + }) + + it('renders no footer when omitted', () => { + const { container } = render( + +

Body

+
, + ) + expect(container.querySelector('.modal__footer')).toBeNull() + }) + + it('wires up testId, contentTestId, titleTestId, titleId and role', () => { + render( + +

Body

+
, + ) + expect(screen.getByTestId('overlay-test')).toHaveAttribute('role', 'dialog') + expect(screen.getByTestId('content-test')).toBeInTheDocument() + expect(screen.getByTestId('title-test')).toHaveAttribute('id', 'hello-title') + }) + + it('omits role when not provided', () => { + const { container } = render( + +

Body

+
, + ) + expect(container.querySelector('.modal__overlay')).not.toHaveAttribute('role') + }) +}) diff --git a/frontend/src/components/common/Modal.tsx b/frontend/src/components/common/Modal.tsx new file mode 100644 index 0000000..d878a36 --- /dev/null +++ b/frontend/src/components/common/Modal.tsx @@ -0,0 +1,96 @@ +import { ReactNode, useEffect } from 'react' +import { Icon } from './Icon' + +export interface ModalProps { + onClose: () => void + title: ReactNode + /** Maps to `.modal__content--{size}` in `src/styles/primitives.css`. */ + size?: 'sm' | 'md' + /** Disables the close button and stops Escape from closing — mirrors + * `QRCodeModal`'s "can't close while the scrape is running" rule. */ + closeDisabled?: boolean + /** Whether clicking the overlay (outside the content box) closes the + * modal. Defaults to true; `QRCodeModal` sets it false so a stray click + * can't drop a running fetch. */ + closeOnOverlayClick?: boolean + footer?: ReactNode + /** `data-testid` on the overlay div. */ + testId?: string + /** `data-testid` on the content box. */ + contentTestId?: string + /** `data-testid` on the title `

`. */ + titleTestId?: string + /** `id` on the title `

`, for a caller that wants to wire up + * `aria-labelledby` on the overlay itself. */ + titleId?: string + /** Set to `'dialog'` when the caller wants that role on the overlay + * (`PlannedDayModal` does; the others don't add one). */ + role?: 'dialog' + closeAriaLabel: string + children: ReactNode +} + +/** + * The shared `.modal__overlay > .modal__content > .modal__header (h2 + + * close button) > .modal__body > .modal__footer` shell — hand-copied across + * `ParentBalanceForm`, `PlannedDayModal` and `QRCodeModal` before this. + * Standardises the close button on ``; the caller still + * supplies its own `aria-label` text. No focus trap — out of scope here. + */ +export function Modal({ + onClose, + title, + size = 'md', + closeDisabled = false, + closeOnOverlayClick = true, + footer, + testId, + contentTestId, + titleTestId, + titleId, + role, + closeAriaLabel, + children, +}: ModalProps): ReactNode { + useEffect(() => { + if (closeDisabled) return + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [closeDisabled, onClose]) + + const handleOverlayClick = () => { + if (closeOnOverlayClick) onClose() + } + + return ( +
+
e.stopPropagation()} + data-testid={contentTestId} + > +
+

+ {title} +

+ +
+ +
{children}
+ + {footer &&
{footer}
} +
+
+ ) +} diff --git a/frontend/src/components/modals/ParentBalanceForm.tsx b/frontend/src/components/modals/ParentBalanceForm.tsx index df6ee38..4a7b7fa 100644 --- a/frontend/src/components/modals/ParentBalanceForm.tsx +++ b/frontend/src/components/modals/ParentBalanceForm.tsx @@ -3,6 +3,7 @@ import { useTranslation } from '@/i18n' import { isApiError, type ApiRequestError } from '@/services/errors' import { FormField } from '@/components/common/FormField' import { Alert } from '@/components/common/Alert' +import { Modal } from '@/components/common/Modal' import './parent-balance-form.css' export interface ParentBalanceInitial { @@ -206,149 +207,131 @@ export function ParentBalanceForm({ } } + const footer = ( + <> + + + + ) + return ( -
-
e.stopPropagation()}> -
-

{isSelf ? t('parentBalance.titleSelf') : t('parentBalance.title', { name: parentName })}

- + +

+ {t('parentBalance.summary', { left: daysLeft, total: totalDays })} +
+ {t('parentBalance.summaryDubbeldagar', { count: dubbeldagarLeft ?? 0 })} +

+ {totalError &&

{totalError}

} + + + + {showAdvanced && ( + - × - -
- -
-

{t('parentBalance.description')}

- - {(error || saveError) && {error || saveError}} - -
- - { - setSjukpenningLeft(parseField(e.target.value)) - clearBackendFieldError('sjukpenning') - }} - disabled={isSaving} - className="field__control" - /> - - - - { - setLagstaNivaLeft(parseField(e.target.value)) - clearBackendFieldError('lagsta') - }} - disabled={isSaving} - className="field__control" - /> - - - - { - setDubbeldagarLeft(parseField(e.target.value)) - clearBackendFieldError('dubbeldagar') - }} - disabled={isSaving} - className="field__control" - /> - - - - -

- {t('parentBalance.summary', { left: daysLeft, total: totalDays })} -
- {t('parentBalance.summaryDubbeldagar', { count: dubbeldagarLeft ?? 0 })} -

- {totalError &&

{totalError}

} - - - - {showAdvanced && ( - - { - setReservedDaysLeft(parseField(e.target.value)) - clearBackendFieldError('reserved') - }} - disabled={isSaving} - className="field__control" - /> - - )} -
-
- -
- - -
+ className="field__control" + /> + + )}
-
+ ) } diff --git a/frontend/src/components/modals/PlannedDayModal.tsx b/frontend/src/components/modals/PlannedDayModal.tsx index 5b658e3..c784e33 100644 --- a/frontend/src/components/modals/PlannedDayModal.tsx +++ b/frontend/src/components/modals/PlannedDayModal.tsx @@ -4,6 +4,8 @@ import { useTranslation } from '@/i18n' import { Icon } from '@/components/common/Icon' import { FormField } from '@/components/common/FormField' import { Alert } from '@/components/common/Alert' +import { Modal } from '@/components/common/Modal' +import { InlineConfirm } from '@/components/common/InlineConfirm' import './planned-day-modal.css' /** The default path — todo.md #10/#11: the planner decides which day type @@ -268,59 +270,205 @@ export function PlannedDayModal({ const parentModeSelectValue = parentMode === 'specific' ? specificParentId : parentMode - return ( -
-
e.stopPropagation()} - data-testid="planned-day-modal__content" - > -
-

{modalTitle}

- + )} + -
+ + + )} + + ) -
- {!isRangeMode &&

{displayDate}

} + return ( + + {!isRangeMode &&

{displayDate}

} - {(error || localError) && {error || localError}} + {(error || localError) && {error || localError}} - {!isRangeMode && - excludedEntries.map((entry) => ( -
( +
+ {t('plannedDayModal.excludedNotice', { parent: entry.parentName })} + {onRestoreExcluded && ( + - )} -
- ))} + {t('plannedDayModal.restoreExcludedButton')} + + )} +
+ ))} -
- {!forcedAdvanced && hasMultipleParents && ( - +
+ {!forcedAdvanced && hasMultipleParents && ( + + + + )} + + {!forcedAdvanced && hasMultipleParents && ( +
+ +

+ {parentMode === 'both' + ? t('plannedDayModal.wantsDubbeldagHint') + : t('plannedDayModal.wantsDubbeldagRequiresBothHint')} +

+
+ )} + + {/* The default path's own copy of the "will stay off" notice — + * `showPinnedControls` is false here (Advanced isn't open), so + * the copy inside that section below never renders for this + * case. Gated on `!showPinnedControls` rather than re-deriving + * "default path" so the two copies can never both render. */} + {!forcedAdvanced && hasMultipleParents && !showPinnedControls && freeDaysWillStayOff && ( +

+ {t('plannedDayModal.freeDaysStayOffNotice')} +

+ )} + + {!forcedAdvanced && hasMultipleParents && ( + + )} + + {showPinnedControls && ( +
+ + + + + {freeDaysWillStayOff && ( +

+ {t('plannedDayModal.freeDaysStayOffNotice')} +

+ )} + + {showPinExclusionNotice && ( +

+ {t('plannedDayModal.pinExcludesFromSyncNotice')} +

+ )} + + {hasMultipleParents && ( + setWantsDubbeldag(e.target.checked)} - disabled={isSaving || parentMode !== 'both'} - data-testid="wants-dubbeldag-checkbox" + checked={isDubbeldag} + onChange={(e) => setIsDubbeldag(e.target.checked)} + disabled={isSaving || dayType === 'vacation'} + data-testid="dubbeldag-checkbox" /> - {' '}{t('plannedDayModal.wantsDubbeldagLabel')} + {' '}{t('plannedDayModal.doubleDayLabel')} -

- {parentMode === 'both' - ? t('plannedDayModal.wantsDubbeldagHint') - : t('plannedDayModal.wantsDubbeldagRequiresBothHint')} -

+ {dayType === 'vacation' && ( +

+ {t('plannedDayModal.doubleDayHint')} +

+ )}
)} - {/* The default path's own copy of the "will stay off" notice — - * `showPinnedControls` is false here (Advanced isn't open), so - * the copy inside that section below never renders for this - * case. Gated on `!showPinnedControls` rather than re-deriving - * "default path" so the two copies can never both render. */} - {!forcedAdvanced && hasMultipleParents && !showPinnedControls && freeDaysWillStayOff && ( -

- {t('plannedDayModal.freeDaysStayOffNotice')} + {existingDay?.isPinned && ( +

+ + {t('plannedDayModal.pinnedNotice')}

)} - - {!forcedAdvanced && hasMultipleParents && ( - - )} - - {showPinnedControls && ( -
- - - - - {freeDaysWillStayOff && ( -

- {t('plannedDayModal.freeDaysStayOffNotice')} -

- )} - - {showPinExclusionNotice && ( -

- {t('plannedDayModal.pinExcludesFromSyncNotice')} -

- )} - - {hasMultipleParents && ( - - - - )} - - {dayType !== 'auto' && ( -
- - {dayType === 'vacation' && ( -

- {t('plannedDayModal.doubleDayHint')} -

- )} -
- )} - - {existingDay?.isPinned && ( -

- - {t('plannedDayModal.pinnedNotice')} -

- )} -
- )}
-
- -
- {!isRangeMode && existingDay && onDelete && isConfirmingDelete && ( -
- - {t('plannedDayModal.confirmDelete')} - - - -
- )} - {!isConfirmingDelete && ( - <> - {!isRangeMode && existingDay && onDelete && ( - - )} - - - - )} -
+ )}
-
+ ) } diff --git a/frontend/src/components/modals/QRCodeModal.tsx b/frontend/src/components/modals/QRCodeModal.tsx index d08dfe2..6beafd9 100644 --- a/frontend/src/components/modals/QRCodeModal.tsx +++ b/frontend/src/components/modals/QRCodeModal.tsx @@ -3,6 +3,7 @@ import type { ScraperStatus } from '@/hooks/useScraperFetch' import { useTranslation, type TranslationKey } from '@/i18n' import { Icon } from '@/components/common/Icon' import { Alert } from '@/components/common/Alert' +import { Modal } from '@/components/common/Modal' import './qrcode-modal.css' // Every `ScraperStatus` maps to a translated `balance.status.*` label — the @@ -47,95 +48,83 @@ export function QRCodeModal({ const { t } = useTranslation() if (!isOpen) return null - const handleClose = () => { - if (isRunning) return - onClose() - } + const footer = + status === 'done' || isRunning ? ( + <> + {status === 'done' && ( + + )} + + {isRunning && ( + + )} + + ) : null return ( -
-
-
-

{t('qrModal.title')}

- -
-
- {status && ( -
-
- {t('qrModal.statusLabel')} - - {t(STATUS_LABEL_KEYS[status])} - - {message && — {message}} -
+ + {status && ( +
+
+ {t('qrModal.statusLabel')} + + {t(STATUS_LABEL_KEYS[status])} + + {message && — {message}} +
- {status === 'awaiting_scan' && qrData && ( -
-

{t('qrModal.qrInstruction')}

- {t('qrModal.qrAlt')} -
- )} - - {(status === 'authenticating' || status === 'fetching') && ( -

- {' '} - {status === 'authenticating' ? t('qrModal.authenticating') : t('qrModal.fetching')} -

- )} - - {status === 'done' && result && ( -

- {t('qrModal.success')} -

- )} - - {status === 'cancelled' && ( -

{t('qrModal.cancelled')}

- )} + {status === 'awaiting_scan' && qrData && ( +
+

{t('qrModal.qrInstruction')}

+ {t('qrModal.qrAlt')}
)} - {error && ( - - {t('qrModal.errorLabel')} {error} - + {(status === 'authenticating' || status === 'fetching') && ( +

+ {' '} + {status === 'authenticating' ? t('qrModal.authenticating') : t('qrModal.fetching')} +

+ )} + + {status === 'done' && result && ( +

+ {t('qrModal.success')} +

+ )} + + {status === 'cancelled' && ( +

{t('qrModal.cancelled')}

)}
+ )} - {(status === 'done' || isRunning) && ( -
- {status === 'done' && ( - - )} - - {isRunning && ( - - )} -
- )} -
-
+ {error && ( + + {t('qrModal.errorLabel')} {error} + + )} + ) } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 99460f1..07a0ac3 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -24,6 +24,7 @@ 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' @@ -780,25 +781,19 @@ export function Settings(): ReactNode { {!isSelf && (isConfirmingRemove ? ( - - {t('settings.removeConfirmPrompt', { name: member.name })} - - - + handleConfirmRemove(member.uuid)} + onCancel={handleCancelRemove} + isBusy={isSavingFamily} + confirmTestId={`remove-confirm-${member.uuid}`} + cancelTestId={`remove-cancel-${member.uuid}`} + confirmFirst + small + /> ) : ( + +
+ + {isRegister && ( + + setDisplayName(e.target.value)} + /> + + )} + + setEmail(e.target.value)} + /> + + + setPassword(e.target.value)} + /> + + {error && {error}} + + + + ) +} diff --git a/frontend/src/pages/Auth.tsx b/frontend/src/pages/Auth.tsx index 1ecdbbe..17ae3a5 100644 --- a/frontend/src/pages/Auth.tsx +++ b/frontend/src/pages/Auth.tsx @@ -4,8 +4,11 @@ import { useAuth } from '@/context/AuthContext' import { useWorkspace } from '@/context/WorkspaceContext' import { useTranslation } from '@/i18n' import { LanguageToggle } from '@/components/layout/LanguageToggle' -import { FormField } from '@/components/common/FormField' -import { Alert } from '@/components/common/Alert' +import { + CredentialsForm, + type CredentialsFormMode, + type CredentialsFormValues, +} from '@/components/auth/CredentialsForm' import './auth.css' export function Auth() { @@ -13,11 +16,9 @@ export function Auth() { const { isLoading: wsLoading } = useWorkspace() const { t } = useTranslation() const navigate = useNavigate() - const [isRegister, setIsRegister] = useState(false) - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [displayName, setDisplayName] = useState('') + const [mode, setMode] = useState('login') const [error, setError] = useState(null) + const isRegister = mode === 'register' useEffect(() => { if (isAuthenticated && !wsLoading) { @@ -25,17 +26,15 @@ export function Auth() { } }, [isAuthenticated, wsLoading, navigate]) - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() + const handleSubmit = async ({ displayName, email, password }: CredentialsFormValues) => { setError(null) try { if (isRegister) { await register({ email, password, displayName }) - navigate('/', { replace: true }) } else { await login({ email, password }) - navigate('/', { replace: true }) } + navigate('/', { replace: true }) } catch (err: unknown) { setError(err instanceof Error ? err.message : t('auth.genericError')) } @@ -44,8 +43,8 @@ export function Auth() { // Switching tabs clears any error from the previous tab's attempt — a // failed login's error text should not linger, confusingly, once the // person has moved on to creating an account (or back). - const switchTab = (register: boolean) => { - setIsRegister(register) + const switchMode = (next: CredentialsFormMode) => { + setMode(next) setError(null) } @@ -57,62 +56,25 @@ export function Auth() {

{isRegister ? t('auth.createAccountTitle') : t('auth.loginTitle')}

-
- - -
-
void handleSubmit(e)}> - {isRegister && ( - - setDisplayName(e.target.value)} - /> - - )} - - setEmail(e.target.value)} - /> - - - setPassword(e.target.value)} - /> - - {error && {error}} - -
+ void handleSubmit(values)} + error={error} + idPrefix="auth" + classPrefix="auth-page" + labels={{ + loginTab: t('auth.loginButton'), + registerTab: t('auth.createAccountButton'), + nameLabel: t('auth.nameLabel'), + namePlaceholder: t('auth.namePlaceholder'), + emailLabel: t('auth.emailLabel'), + emailPlaceholder: t('auth.emailPlaceholder'), + passwordLabel: t('auth.passwordLabel'), + passwordPlaceholder: t('auth.passwordPlaceholder'), + submitLabel: isRegister ? t('auth.registerButton') : t('auth.loginButton'), + }} + />
) diff --git a/frontend/src/pages/Join.tsx b/frontend/src/pages/Join.tsx index a4f4498..08052cf 100644 --- a/frontend/src/pages/Join.tsx +++ b/frontend/src/pages/Join.tsx @@ -5,12 +5,15 @@ import { useWorkspace } from '@/context/WorkspaceContext' import { redeemInvitation } from '@/services/api' import { useTranslation, type TranslationKey } from '@/i18n' import { LanguageToggle } from '@/components/layout/LanguageToggle' -import { FormField } from '@/components/common/FormField' import { Alert } from '@/components/common/Alert' +import { + CredentialsForm, + type CredentialsFormMode, + type CredentialsFormValues, +} from '@/components/auth/CredentialsForm' import './join.css' type Phase = 'checking' | 'form' | 'redeeming' | 'error' | 'done' -type Mode = 'login' | 'register' /** Maps a redeem/register failure's backend message to a translated, * distinct reason. The backend (see `InvitationError` call sites in @@ -43,10 +46,7 @@ export function Join() { const [phase, setPhase] = useState('checking') const [errorKey, setErrorKey] = useState(null) - const [mode, setMode] = useState('register') - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [displayName, setDisplayName] = useState('') + const [mode, setMode] = useState('register') const [isSubmitting, setIsSubmitting] = useState(false) // Guards against redeeming twice: once from a submit handler and again @@ -84,8 +84,7 @@ export function Join() { } }, [authLoading, isAuthenticated, token, redeemAndGo]) - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() + const handleSubmit = async ({ displayName, email, password }: CredentialsFormValues) => { if (!token) return setErrorKey(null) setIsSubmitting(true) @@ -113,7 +112,7 @@ export function Join() { } } - const switchMode = (next: Mode) => { + const switchMode = (next: CredentialsFormMode) => { setMode(next) setErrorKey(null) } @@ -152,75 +151,32 @@ export function Join() { )} {phase === 'form' && ( - <> -
- - -
-
void handleSubmit(e)}> - {mode === 'register' && ( - - setDisplayName(e.target.value)} - /> - - )} - - setEmail(e.target.value)} - /> - - - setPassword(e.target.value)} - /> - - {errorKey && {t(errorKey)}} - -
- + void handleSubmit(values)} + error={errorKey ? t(errorKey) : null} + disabled={isSubmitting} + idPrefix="join" + classPrefix="join-page" + labels={{ + loginTab: t('invite.joinLoginTab'), + registerTab: t('invite.joinRegisterTab'), + nameLabel: t('invite.joinNameLabel'), + namePlaceholder: t('invite.joinNamePlaceholder'), + emailLabel: t('invite.joinEmailLabel'), + emailPlaceholder: t('invite.joinEmailPlaceholder'), + passwordLabel: t('invite.joinPasswordLabel'), + passwordPlaceholder: t('invite.joinPasswordPlaceholder'), + submitLabel: isSubmitting + ? mode === 'register' + ? t('invite.joinSubmittingRegister') + : t('invite.joinSubmittingLogin') + : mode === 'register' + ? t('invite.joinSubmitRegister') + : t('invite.joinSubmitLogin'), + }} + /> )}
From 49e7c01e1283390cb26eacbd0a8c759b28e019d8 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Tue, 22 Sep 2026 22:27:13 +0200 Subject: [PATCH 4/5] Share the tip card shell and the leave-type swatch styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TipCardShell replaces the tip-card markup that PlanWarningCard, SurplusAdviceTip and VacationRescueTip each copied from TipCard. The vacation/sjukpenning/lägsta fills and cap marks move to .leave-swatch--* in primitives.css; the calendar band, year rows and help popover keep only their size, position and deliberate overrides. Co-Authored-By: Claude Opus 5.5 --- .../calendar/CalendarHelpPopover.tsx | 20 ++- .../calendar/calendar-help-popover.css | 21 +--- .../src/components/common/CalendarWeek.tsx | 7 +- .../components/common/TipCardShell.test.tsx | 114 ++++++++++++++++++ .../src/components/common/TipCardShell.tsx | 68 +++++++++++ .../components/dashboard/PlanWarningCard.tsx | 16 ++- .../components/dashboard/SurplusAdviceTip.tsx | 16 ++- .../dashboard/VacationRescueTip.tsx | 21 ++-- .../src/components/dashboard/YearRows.tsx | 7 +- frontend/src/components/inputs/TipCard.tsx | 35 ++---- frontend/src/pages/calendar.css | 30 ++--- frontend/src/styles/components.css | 21 ++-- frontend/src/styles/primitives.css | 60 +++++++++ 13 files changed, 328 insertions(+), 108 deletions(-) create mode 100644 frontend/src/components/common/TipCardShell.test.tsx create mode 100644 frontend/src/components/common/TipCardShell.tsx diff --git a/frontend/src/components/calendar/CalendarHelpPopover.tsx b/frontend/src/components/calendar/CalendarHelpPopover.tsx index 647a929..a042bbd 100644 --- a/frontend/src/components/calendar/CalendarHelpPopover.tsx +++ b/frontend/src/components/calendar/CalendarHelpPopover.tsx @@ -16,10 +16,22 @@ const DAY_MARKS: ReadonlyArray<{ key: string; className: string; labelKey: Trans /** The band styles: type by fill/ink/outline, owner by the left cap. */ const BAND_MARKS: ReadonlyArray<{ key: string; className: string; labelKey: TranslationKey; text?: string }> = [ { key: 'vacation', className: 'calendar-help__band calendar-help__band--vacation', labelKey: 'calendar.helpVacation' }, - { key: 'sjukpenning', className: 'calendar-help__band calendar-help__band--sjukpenning', labelKey: 'calendar.helpSjukpenning' }, - { key: 'lagsta', className: 'calendar-help__band calendar-help__band--lagsta', labelKey: 'calendar.helpLagsta' }, - { key: 'self', className: 'calendar-help__band calendar-help__band--self', labelKey: 'calendar.helpSelf' }, - { key: 'partner', className: 'calendar-help__band calendar-help__band--partner', labelKey: 'calendar.helpPartner' }, + { + key: 'sjukpenning', + className: 'calendar-help__band calendar-help__band--sjukpenning leave-swatch--sjukpenning', + labelKey: 'calendar.helpSjukpenning', + }, + { + key: 'lagsta', + className: 'calendar-help__band calendar-help__band--lagsta leave-swatch--lagsta', + labelKey: 'calendar.helpLagsta', + }, + { key: 'self', className: 'calendar-help__band calendar-help__band--self leave-swatch--cap-self', labelKey: 'calendar.helpSelf' }, + { + key: 'partner', + className: 'calendar-help__band calendar-help__band--partner leave-swatch--cap-partner', + labelKey: 'calendar.helpPartner', + }, { key: 'dubbeldag', className: 'calendar-help__band calendar-help__band--dubbeldag', labelKey: 'calendar.helpDubbeldag', text: '2×' }, { key: 'pinned', className: 'calendar-help__band calendar-help__band--pinned', labelKey: 'calendar.helpPinned' }, ] diff --git a/frontend/src/components/calendar/calendar-help-popover.css b/frontend/src/components/calendar/calendar-help-popover.css index 59be733..4fe99b2 100644 --- a/frontend/src/components/calendar/calendar-help-popover.css +++ b/frontend/src/components/calendar/calendar-help-popover.css @@ -127,30 +127,19 @@ color: var(--c-leave-vacation-ink); } +/* Fill/hatch/outline and the cap-mark shadow are the shared + * `.leave-swatch--*` recipe from primitives.css (added alongside these + * classes in CalendarHelpPopover.tsx); ink stays local since it isn't part + * of that shared recipe (the calendar band and year-row swatch don't set + * it via CSS either — see leaveTypes.ts's `getInkForType`). */ .calendar-help__band--sjukpenning { - background-color: var(--c-leave-sjukpenning); color: var(--c-leave-sjukpenning-ink); } .calendar-help__band--lagsta { - background-color: var(--c-leave-lagsta); - background-image: repeating-linear-gradient( - 135deg, - transparent 0 4px, - var(--c-leave-lagsta-hatch) 4px 5px - ); - box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline); color: var(--c-leave-lagsta-ink); } -.calendar-help__band--self { - box-shadow: inset 2px 0 0 var(--c-band-cap-self); -} - -.calendar-help__band--partner { - box-shadow: inset 2px 0 0 var(--c-band-cap-partner); -} - .calendar-help__band--pinned { box-shadow: inset 0 -2px 0 var(--c-leave-vacation-ink); } diff --git a/frontend/src/components/common/CalendarWeek.tsx b/frontend/src/components/common/CalendarWeek.tsx index 6fe6455..eba42d8 100644 --- a/frontend/src/components/common/CalendarWeek.tsx +++ b/frontend/src/components/common/CalendarWeek.tsx @@ -402,10 +402,15 @@ export function CalendarWeek({ // see the --c-leave-* tokens) and owner by a 2px left cap // (`--self`/`--partner`) — never by hue, so the two axes stay // readable side by side and in greyscale. + const typeKey = getTypeKey(representativeType) const classNames = [ 'calendar-band', - `calendar-band--${getTypeKey(representativeType)}`, + `calendar-band--${typeKey}`, + // `leave-swatch--*` (primitives.css) carries the shared + // fill/hatch/outline/cap-mark recipe; `unknown` has no recipe. + typeKey !== 'unknown' ? `leave-swatch--${typeKey}` : '', segment.parentUuid === personalDataUuid ? 'calendar-band--self' : 'calendar-band--partner', + segment.parentUuid === personalDataUuid ? 'leave-swatch--cap-self' : 'leave-swatch--cap-partner', segment.isBandStart ? 'calendar-band--start' : '', segment.isBandEnd ? 'calendar-band--end' : '', segment.wishId && segment.wishId === flashWishId ? 'calendar-band--flash' : '', diff --git a/frontend/src/components/common/TipCardShell.test.tsx b/frontend/src/components/common/TipCardShell.test.tsx new file mode 100644 index 0000000..935e17f --- /dev/null +++ b/frontend/src/components/common/TipCardShell.test.tsx @@ -0,0 +1,114 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { TipCardShell } from './TipCardShell' + +describe('TipCardShell', () => { + it.each([ + ['info', 'tip-card--info'], + ['warning', 'tip-card--warning'], + ['action', 'tip-card--action'], + ['danger', 'tip-card--danger'], + ] as const)('renders the %s variant with role="article" and the matching class', (variant, cls) => { + render( + +

Body

+
, + ) + const el = screen.getByRole('article') + expect(el).toHaveClass('tip-card', cls) + }) + + it('renders the title in a tip-card__title heading and the children below the header', () => { + render( + +

My body

+
, + ) + const el = screen.getByRole('article') + expect(el.querySelector('.tip-card__title')?.textContent).toBe('My title') + expect(el.querySelector('.tip-card__body')?.textContent).toBe('My body') + }) + + it('renders without a data-testid when none is given', () => { + render( + +

Body

+
, + ) + expect(screen.getByRole('article')).not.toHaveAttribute('data-testid') + }) + + it('applies a testId when given', () => { + render( + +

Body

+
, + ) + expect(screen.getByTestId('my-tip')).toBe(screen.getByRole('article')) + }) + + it('applies the aria-label when given', () => { + render( + +

Body

+
, + ) + expect(screen.getByRole('article')).toHaveAttribute('aria-label', 'Tips: T') + }) + + it('appends a custom className alongside the variant class', () => { + render( + +

Body

+
, + ) + expect(screen.getByRole('article')).toHaveClass('tip-card', 'tip-card--action', 'vacation-rescue-tip') + }) + + it('does not render a dismiss button when onDismiss is not provided', () => { + render( + +

Body

+
, + ) + expect(screen.queryByTestId('dismiss-button')).not.toBeInTheDocument() + }) + + it('renders a dismiss button with the given label and calls onDismiss when clicked', () => { + const onDismiss = vi.fn() + render( + +

Body

+
, + ) + const button = screen.getByTestId('dismiss-button') + expect(button).toHaveAttribute('aria-label', 'Dismiss T') + fireEvent.click(button) + expect(onDismiss).toHaveBeenCalledTimes(1) + }) + + it('renders headerExtra content instead of relying on the generic dismiss button', () => { + render( + Bye}> +

Body

+
, + ) + expect(screen.getByTestId('custom-dismiss')).toBeInTheDocument() + expect(screen.queryByTestId('dismiss-button')).not.toBeInTheDocument() + }) + + it('renders both the generic dismiss button and headerExtra when both are given', () => { + render( + {}} + headerExtra={Extra} + > +

Body

+
, + ) + expect(screen.getByTestId('dismiss-button')).toBeInTheDocument() + expect(screen.getByTestId('extra')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/common/TipCardShell.tsx b/frontend/src/components/common/TipCardShell.tsx new file mode 100644 index 0000000..a72e941 --- /dev/null +++ b/frontend/src/components/common/TipCardShell.tsx @@ -0,0 +1,68 @@ +import { ReactNode } from 'react' + +export type TipCardVariant = 'info' | 'warning' | 'action' | 'danger' + +export interface TipCardShellProps { + variant: TipCardVariant + title: ReactNode + children: ReactNode + /** Renders the generic "×" dismiss button (see `TipCard`'s own use) when + * provided. Omit this and use `headerExtra` instead when a consumer needs + * a differently-styled header control, e.g. `VacationRescueTip`'s labelled + * dismiss button with its own testid/disabled state. */ + onDismiss?: (() => void) | undefined + /** aria-label for the generic dismiss button; ignored without `onDismiss`. */ + dismissLabel?: string | undefined + /** Custom content rendered in the header after the title, in place of the + * generic dismiss button. */ + headerExtra?: ReactNode + testId?: string | undefined + ariaLabel?: string | undefined + className?: string | undefined +} + +/** + * The `tip-card` / `tip-card__header` / `tip-card__title` shell shared by + * `TipCard` and the dashboard's derived tip-like cards (`PlanWarningCard`, + * `SurplusAdviceTip`, `VacationRescueTip`) — so the dashboard reads as one + * visual system. Body content (and anything beyond it, e.g. + * `VacationRescueTip`'s date list) is left to `children`, since it varies + * per consumer. + */ +export function TipCardShell({ + variant, + title, + children, + onDismiss, + dismissLabel, + headerExtra, + testId, + ariaLabel, + className, +}: TipCardShellProps): ReactNode { + return ( +
+
+

{title}

+ {onDismiss && ( + + )} + {headerExtra} +
+ {children} +
+ ) +} diff --git a/frontend/src/components/dashboard/PlanWarningCard.tsx b/frontend/src/components/dashboard/PlanWarningCard.tsx index a2644ef..b1b0276 100644 --- a/frontend/src/components/dashboard/PlanWarningCard.tsx +++ b/frontend/src/components/dashboard/PlanWarningCard.tsx @@ -2,6 +2,7 @@ import { ReactNode } from 'react' import type { PlanWarning } from '@/types' import { useTranslation, toTranslationParams, type TranslationKey } from '@/i18n' import { formatDateForDisplay } from '@/utils/dates' +import { TipCardShell } from '@/components/common/TipCardShell' // The three `PlanWarning` kinds the backend can emit // (`allocation_service.py`'s `_sgi_risk_warnings`, `_age4_warnings`, @@ -88,16 +89,13 @@ export function PlanWarningCard({ warning, nameFor, index }: PlanWarningCardProp } return ( -
-
-

{title}

-

{body}

-
+ ) } diff --git a/frontend/src/components/dashboard/SurplusAdviceTip.tsx b/frontend/src/components/dashboard/SurplusAdviceTip.tsx index af1af68..a0c2bc3 100644 --- a/frontend/src/components/dashboard/SurplusAdviceTip.tsx +++ b/frontend/src/components/dashboard/SurplusAdviceTip.tsx @@ -1,6 +1,7 @@ import { ReactNode } from 'react' import type { SurplusAdvice } from '@/types' import { useTranslation, toTranslationParams, type TranslationKey } from '@/i18n' +import { TipCardShell } from '@/components/common/TipCardShell' // §9.2 surplus-advice kinds — `kind` -> title, `messageKey` -> the body // template that interpolates the advice's own `data`. An unrecognised kind @@ -51,16 +52,13 @@ export function SurplusAdviceTip({ advice, nameFor, index }: SurplusAdviceTipPro } return ( -
-
-

{title}

-

{body}

-
+ ) } diff --git a/frontend/src/components/dashboard/VacationRescueTip.tsx b/frontend/src/components/dashboard/VacationRescueTip.tsx index 891eac3..6d4f755 100644 --- a/frontend/src/components/dashboard/VacationRescueTip.tsx +++ b/frontend/src/components/dashboard/VacationRescueTip.tsx @@ -3,6 +3,7 @@ import type { RescueDay } from '@/types' import { useTranslation } from '@/i18n' import { useRescueDayActions, RescueDismissedHandler } from '@/hooks/useRescueDayActions' import { formatDateForDisplay } from '@/utils/dates' +import { TipCardShell } from '@/components/common/TipCardShell' export interface VacationRescueTipProps { workspaceId: string | null @@ -51,14 +52,13 @@ export function VacationRescueTip({ }) return ( -
-
-

{title}

+ {t('planResults.vacationRescueDismiss')} -
+ } + >

{body}

    @@ -107,6 +108,6 @@ export function VacationRescueTip({ {t('planResults.vacationRescueError')}

    )} -
+ ) } diff --git a/frontend/src/components/dashboard/YearRows.tsx b/frontend/src/components/dashboard/YearRows.tsx index f840b89..ff4a8d9 100644 --- a/frontend/src/components/dashboard/YearRows.tsx +++ b/frontend/src/components/dashboard/YearRows.tsx @@ -215,7 +215,10 @@ export function YearRows({
    {TYPE_ORDER.map((type) => (
  • -
  • ))} @@ -259,7 +262,7 @@ export function YearRows({ - )} -

{body}

-

+ ) } diff --git a/frontend/src/pages/calendar.css b/frontend/src/pages/calendar.css index 543977a..8bb8ad8 100644 --- a/frontend/src/pages/calendar.css +++ b/frontend/src/pages/calendar.css @@ -416,28 +416,14 @@ border-radius: inherit; } -.calendar-band--self::after { - box-shadow: inset 2px 0 0 var(--c-band-cap-self); -} - -.calendar-band--partner::after { - box-shadow: inset 2px 0 0 var(--c-band-cap-partner); -} - -/* Lägsta is a white fill told apart by a 1px dark outline and a hatch, not - * a hue (see the --c-leave-* tokens); the hatch sits on each lägsta tick so - * a mixed-type period still shows which days are which. */ -.calendar-band--lagsta::after { - box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline); -} - -.calendar-band--lagsta.calendar-band--self::after { - box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline), inset 2px 0 0 var(--c-band-cap-self); -} - -.calendar-band--lagsta.calendar-band--partner::after { - box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline), inset 2px 0 0 var(--c-band-cap-partner); -} +/* The self/partner cap-mark and lägsta's outline are the shared + * `.leave-swatch--cap-self`/`--cap-partner`/`--lagsta` recipe from + * primitives.css, scoped to `::after` here — see the comment there and on + * `.calendar-band::after` above for why. `getTypeKey`'s `--lagsta`/ + * `leave-swatch--lagsta` and the owner's `--self`/`--partner`/ + * `leave-swatch--cap-*` are added side by side in CalendarWeek.tsx, so + * lägsta's outline and the owner's cap keep composing even though this + * file no longer states the box-shadow values itself. */ .calendar-band__day--lagsta { background-image: repeating-linear-gradient( diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 4493eca..4c56302 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -199,27 +199,30 @@ flex-shrink: 0; } +/* Fill/hatch/outline is the shared `.leave-swatch--*` recipe from + * primitives.css (added alongside these classes in YearRows.tsx); this file + * keeps only what's genuinely different at this swatch's small size. + * + * Vacation's bar uses a distinct, more saturated fill + * (--c-leave-vacation-bar) than the calendar band/help-popover sample — + * needed for contrast at 14px/8px. Overrides `.leave-swatch--vacation`'s + * background-color: same specificity, later in the cascade (components.css + * loads after primitives.css). */ .year-rows__swatch--vacation, .year-row__band--vacation { background-color: var(--c-leave-vacation-bar); } -.year-rows__swatch--sjukpenning, -.year-row__band--sjukpenning { - background-color: var(--c-leave-sjukpenning); -} - -/* Lägsta is a white fill told apart by a 1px dark outline and a hatch, not - * a hue (see the --c-leave-* tokens). */ +/* Lägsta's hatch is finer here (3px/4px stops) than the calendar band/ + * help-popover's 4px/5px, for the same reason. Overrides + * `.leave-swatch--lagsta`'s background-image the same way. */ .year-rows__swatch--lagsta, .year-row__band--lagsta { - background-color: var(--c-leave-lagsta); background-image: repeating-linear-gradient( 135deg, transparent 0 3px, var(--c-leave-lagsta-hatch) 3px 4px ); - box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline); } .year-row__summary { diff --git a/frontend/src/styles/primitives.css b/frontend/src/styles/primitives.css index b736d3c..c3f1797 100644 --- a/frontend/src/styles/primitives.css +++ b/frontend/src/styles/primitives.css @@ -587,6 +587,66 @@ } } +/* Leave-type swatches + * ------------------------------------------------------------------------ + * Leave types are told apart by fill/hatch/outline, never by hue (see the + * `--c-leave-*` tokens in global.css), and the owning parent by a 2px left + * cap-mark (`--c-band-cap-*`). This recipe used to be hand-copied across + * the calendar band, the year-row swatch/timeline and the calendar-help + * legend; consumers keep only their own size/position rule and add one of + * these classes alongside their existing BEM modifier. + * + * `--lagsta`/`--cap-self`/`--cap-partner` are defined twice: once for a + * plain element (the year-row swatch, the calendar-help sample — nothing + * paints over them) and once scoped to `::after` (the calendar band, whose + * per-day ticks sit on top of the band's own background and would hide an + * inset shadow drawn directly on it — see `.calendar-band::after` in + * calendar.css). The combined lagsta+cap-self / lagsta+cap-partner + * `::after` rules exist because `box-shadow` doesn't merge across two + * separate rules — the more specific combined selector is what lets a + * lägsta band still show its owner's cap. */ +.leave-swatch--vacation { + background-color: var(--c-leave-vacation); +} + +.leave-swatch--sjukpenning { + background-color: var(--c-leave-sjukpenning); +} + +.leave-swatch--lagsta { + background-color: var(--c-leave-lagsta); + background-image: repeating-linear-gradient(135deg, transparent 0 4px, var(--c-leave-lagsta-hatch) 4px 5px); + box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline); +} + +.leave-swatch--lagsta::after { + box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline); +} + +.leave-swatch--cap-self { + box-shadow: inset 2px 0 0 var(--c-band-cap-self); +} + +.leave-swatch--cap-self::after { + box-shadow: inset 2px 0 0 var(--c-band-cap-self); +} + +.leave-swatch--cap-partner { + box-shadow: inset 2px 0 0 var(--c-band-cap-partner); +} + +.leave-swatch--cap-partner::after { + box-shadow: inset 2px 0 0 var(--c-band-cap-partner); +} + +.leave-swatch--lagsta.leave-swatch--cap-self::after { + box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline), inset 2px 0 0 var(--c-band-cap-self); +} + +.leave-swatch--lagsta.leave-swatch--cap-partner::after { + box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline), inset 2px 0 0 var(--c-band-cap-partner); +} + /* Layout helpers * ------------------------------------------------------------------------ */ .stack { From 4e95b5c160e2692299b4408df09e3e17ffcff82b Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Wed, 23 Sep 2026 07:30:24 +0200 Subject: [PATCH 5/5] update todo --- new-features/alignment-plan.md | 116 ------------- new-features/fix-deprecations-backend-plan.md | 69 -------- ...ber-drop-redundant-preschool-field-plan.md | 154 ------------------ .../free-days-and-auto-planning-plan.md | 102 ------------ todo.md | 38 +---- 5 files changed, 4 insertions(+), 475 deletions(-) delete mode 100644 new-features/alignment-plan.md delete mode 100644 new-features/fix-deprecations-backend-plan.md delete mode 100644 new-features/fix-number-drop-redundant-preschool-field-plan.md delete mode 100644 new-features/free-days-and-auto-planning-plan.md diff --git a/new-features/alignment-plan.md b/new-features/alignment-plan.md deleted file mode 100644 index daebbde..0000000 --- a/new-features/alignment-plan.md +++ /dev/null @@ -1,116 +0,0 @@ -# Align the year-row timelines in the Overview tab - -## Context - -In the Dashboard Overview tab each year is a `
` whose -`` is its **own** grid: `grid-template-columns: 3.5rem minmax(0, 1fr) auto var(--icon-sm)` -(`frontend/src/styles/components.css:225`). Because every row is an independent grid, the `auto` -figures column is sized per row by that row's own text — "· expiring 5", a wider `saved` number, -a longer parent name, a 3-digit vs 2-digit PL value. The `minmax(0, 1fr)` track column absorbs the -difference, so the timeline bars end at a different x per year (see `timeline-offset.png`: 2029's -track is clearly shortest, 2025 the longest). The figures columns are ragged for the same reason. - -Goal: every year's timeline track starts and ends at the same x, and the figure columns line up -down the list — without hardcoding a pixel width that breaks in Swedish (sv strings are longer than -en) or when a number gains a digit. - -## Approach — one shared grid via `subgrid` - -Promote the column definition from each summary to the `.year-rows` list and have the rows opt into -it with `grid-template-columns: subgrid`. Tracks are then sized once, by the widest content in the -whole list, and every row inherits identical column widths. Content still sizes the columns, so it -stays bilingual-safe. - -Split the figures cell into its three real columns (name | PL left | vacation group) at the list -level too, so the numbers align vertically across years as well. - -**CSS only — no TSX changes.** The existing markup already has the right elements -(`.year-rows` → `.year-row` (`details`) → `.year-row__summary` / `.year-row__body`, -`.year-row__figures` → `.year-row__parent { display: contents }`). - -### Files - -- `frontend/src/styles/components.css` — `.year-rows` (162), `.year-row__summary` (225), - `.year-row__summary::after` (238), `.year-row__year` (244), `.year-row__track` (252), - `.year-row__figures` (289), `.year-row__body` (322) -- `frontend/src/styles/responsive.css` — mobile block at 93–120 (must reset subgrid) - -### Shape - -`.year-rows` becomes the grid owner: - -```css -.year-rows { - display: grid; - /* year | track | name | PL left | vacation group | chevron */ - grid-template-columns: 3.5rem minmax(0, 1fr) auto auto auto var(--icon-sm); -} -``` - -Each row spans it and re-exposes it: - -```css -.year-row { /* the
*/ - grid-column: 1 / -1; - display: grid; - grid-template-columns: subgrid; -} -.year-row__summary { - grid-column: 1 / -1; - display: grid; - grid-template-columns: subgrid; - /* keep: align-items, column-gap, row-gap, padding, font-weight */ -} -.year-row__body { grid-column: 1 / -1; } -``` - -Column placement inside the summary shifts to match the six tracks: year `1`, track `2`, -`.year-row__figures` `grid-column: 3 / 6` + `grid-template-columns: subgrid` (keep -`display: grid`, `align-items: baseline`, `white-space: nowrap`; drop its own -`auto auto auto`), chevron `::after` `grid-column: 6`. `.year-row__parent { display: contents }` -stays — each parent's three spans land in the three shared columns. - -Notes: -- `--sp-4` column-gap must be declared on `.year-rows` as well, since a subgrid inherits the - parent's gaps unless it sets its own; keep the summary's `column-gap` so the values agree. -- `.year-rows > .disclosure:last-child { border-bottom: none }` and the `.disclosure` hairlines - in `primitives.css` are unaffected (borders on the `details` still paint). -- In the solo case `.year-row__name` isn't rendered; that column collapses to `0` for every row — - still uniform. -- Left-over `grid-row: 1` declarations on year/track/figures stay valid. - -### Fallback - -Wrap the promotion in a support check so older engines keep today's (merely ragged) layout rather -than collapsing: - -```css -@supports (grid-template-columns: subgrid) { /* the rules above */ } -``` - -Keep the current `.year-row__summary` rule as the base; the `@supports` block overrides it. - -### Mobile - -`responsive.css` already re-lays the summary as `3rem minmax(0, 1fr) var(--icon-sm)` with the -figures wrapped to row 2 as a flex column. Inside that media query, reset the new rules: -`.year-rows { display: flex; flex-direction: column }` and -`.year-row { display: block }` — so the mobile block's existing declarations apply unchanged. -Place these resets after the `@supports` block in source order, or repeat them inside their own -`@supports` guard, so specificity/order lets mobile win. - -## Verification - -1. `cd frontend && npm run dev`, open the Dashboard Overview tab with the demo workspace - (`demo@example.com`) — delegate the browser pass to a Sonnet subagent (never drive Chrome from - the coordinator). -2. Check: every year's track left and right edge align to the pixel, including 2029 (shortest - figures) vs 2025 (no expiring) vs a row with `expiring`; the "PL left" and "Vacation left" - labels form straight columns. -3. Repeat in **both** Swedish and English (longer sv strings are the real stress test), and in a - solo workspace (no parent names) as well as a couple workspace. -4. Narrow to the mobile breakpoint and confirm figures still wrap under the track, one parent per - line (use the same-origin iframe trick — `resize_window` is a no-op in this setup). -5. Gates (delegate to a Haiku subagent, raw output): from `frontend/` `npm run typecheck`, - `npm run lint`, `npm run coverage`. CSS-only change — `YearRows.test.tsx` and `Dashboard.test.tsx` - should be untouched and green. diff --git a/new-features/fix-deprecations-backend-plan.md b/new-features/fix-deprecations-backend-plan.md deleted file mode 100644 index d6c8dfb..0000000 --- a/new-features/fix-deprecations-backend-plan.md +++ /dev/null @@ -1,69 +0,0 @@ -# Fix backend deprecation warnings - -## Context - -`uv run pytest` on `backend/` emits 26 `DeprecationWarning`s. They are noise in CI output and two of the four sources are real forward-compat debt in our own code — the Starlette 422 constant is already renamed upstream, and Alembic's `version_path_separator` is superseded. Goal: our code emits zero deprecation warnings. - -Confirmed inventory (Python 3.14.0, fastapi 0.136.3, starlette 1.2.1, alembic 1.18.4, slowapi 0.1.9): - -| Source | Count | Ours? | -|---|---|---| -| `HTTP_422_UNPROCESSABLE_ENTITY` → use `HTTP_422_UNPROCESSABLE_CONTENT` | 8 | yes — 20 call sites | -| `alembic/config.py:612` — `No path_separator found in configuration` | 12 | yes — `backend/alembic.ini` | -| `slowapi/extension.py:717` — `asyncio.iscoroutinefunction` | 6 | no — upstream, out of scope | - -Decision made: fix our code only. No `filterwarnings` config is added; the slowapi warning stays visible as an upstream reminder. - -**Baseline:** 4 tests already fail on `main` (`test_migration_004_backfill.py::test_flexible_yearly_wish_keeps_the_same_bounding_range_every_year`, `test_leave_wish_repository.py::…test_flexible_series_keeps_one_bounding_range_per_year_unshifted`, two in `test_allocation_service.py`). These come from the uncommitted in-flight work in `app/entities/leave_wish.py` / `app/services/allocation_service.py` and are **unrelated** to this change. Expected result after the change: still exactly those 4, no more. - -## Change 1 — rename the 422 constant (20 sites, 15 files) - -Mechanical rename, same numeric value (422), so no wire-format or frontend impact: - -``` -status.HTTP_422_UNPROCESSABLE_ENTITY → status.HTTP_422_UNPROCESSABLE_CONTENT -``` - -All sites are in `backend/app/routers/`. Representative files: `workspace.py` (3 sites, lines 51/94/223 — inside conditional expressions, not plain raises), `settings.py` (3), `fetch_data.py` (2), `leave_wishes.py` (2), `plan.py` (2), `preschool_planning_days.py` (2), plus one each in `auth.py`, `calendar.py`, `day_overrides.py`, `planned_day_exclusions.py`, `planned_days.py`, `vacation_days.py`. - -Notes: -- `status` is already imported from `fastapi` in every one of these files — no import changes. -- `status.HTTP_422_UNPROCESSABLE_CONTENT` is verified present in the installed starlette (evaluates to `422`). -- No test file references the constant by name (`git grep UNPROCESSABLE -- tests` is empty), so tests need no update. -- The warnings attributed to `fastapi/routing.py:328` are our handler frames raising through the dependency call — they disappear with this rename too. - -## Change 2 — Alembic `path_separator` - -`backend/alembic.ini` currently has: - -```ini -prepend_sys_path = . -version_path_separator = os -``` - -Replace `version_path_separator = os` with `path_separator = os`. In Alembic ≥1.16 `path_separator` governs both `prepend_sys_path` and `version_locations`, and its absence is what triggers the legacy-splitting warning. Leave `prepend_sys_path = .` and the annotated `sqlalchemy.url` fallback untouched. - -## Out of scope - -- slowapi's `asyncio.iscoroutinefunction` warning — upstream, no released fix. -- `typing.Dict` in `app/utils/holidays.py` and `datetime.timezone.utc` in `app/services/auth_service.py` — older spellings, but not deprecated and not warning sources. -- The 4 pre-existing test failures. - -## Verification - -Delegate to a **Haiku** subagent (per the cost rule); require raw verbatim output, from `backend/`: - -```bash -uv run ruff check . -uv run ruff format --check . -uv run pytest -uv run alembic upgrade head # exercises the alembic.ini change directly -``` - -Acceptance criteria: -1. The warnings summary contains **only** the 6 `slowapi/extension.py:717` lines — no `UNPROCESSABLE_ENTITY`, no `path_separator`, count down from 26 to 6. -2. Failure list is exactly the 4 baseline failures above; pass count unchanged at 860. -3. Coverage still ≥ the `--cov-fail-under=80` gate (no gate changes). -4. `alembic upgrade head` completes without the `path_separator` warning. - -Then `graphify update .`. diff --git a/new-features/fix-number-drop-redundant-preschool-field-plan.md b/new-features/fix-number-drop-redundant-preschool-field-plan.md deleted file mode 100644 index 57432f5..0000000 --- a/new-features/fix-number-drop-redundant-preschool-field-plan.md +++ /dev/null @@ -1,154 +0,0 @@ -# Fix number-input clearing UX + drop the redundant preschool estimate field - -## Context - -Two problems in Settings, both reported by the user: - -1. **Clearing a number input traps you at `0`.** The per-year "Planned days" input - (`Settings.tsx:186-192`) keeps its draft as a `number`, and `Number('') === 0`, so - blanking the box writes a literal `0` to the server and snaps the display to `0`. - Typing `5` then produces `05`. The same hazard exists in `VacationDaysTable` and - `PeriodPopover`. `VacationDaysInput` has the mirror bug: it silently rejects a - non-numeric value, so the field can't be blanked to retype at all. - -2. **Two preschool controls look duplicated.** "Preschool planning days per year" - (the child-level estimate, `Child.preschoolPlanningDaysPerYear`) sits directly above - the "Planning days per year" heading for the per-year rows. They are not duplicates — - the estimate seeds each year row and is the allocator's fallback for years with no - saved row (`backend/app/services/allocation_service.py:545-573`, `:2985-3001`) — but - the UI gives no hint of that, so it reads as dead weight. - -**Decisions taken:** remove the estimate *input* from the UI while keeping the backend -field (default `6`) as the seed/fallback; extract one shared number input and roll it out -to every numeric field that still has the `Number('') === 0` hazard. - -**Out of scope (flagged, not fixed):** `backend/app/services/tips_service.py:579` passes -the *global* `settings.rules.preschool_planning_days_per_year` into -`preschool_buffer_needed` instead of the child's own value, so a customised per-child -value is ignored by that tip. Separate bug, separate change. - -## 1. New shared component: `frontend/src/components/inputs/NumberField.tsx` - -Lift the working pattern already used by the "Days taken" input (`Settings.tsx:199-217`). - -```tsx -export interface NumberFieldProps { - value: number - onChange: (value: number) => void - label?: string - min?: number - max?: number - id?: string - className?: string - inputClassName?: string - disabled?: boolean - placeholder?: string - 'data-testid'?: string - 'aria-invalid'?: boolean - 'aria-describedby'?: string -} -``` - -Behaviour contract: - -- Holds an internal **string** draft. `''` is a legal transient state. -- `onChange` fires only when the draft parses to a finite number — **never on empty**, so - the previously saved value survives while the box is blank. -- No clamping while typing (clamping mid-keystroke is what makes small `max` values - unusable). Clamp to `[min, max]` on blur only, and commit the clamped value. -- On blur with an empty or unparseable draft: revert the draft to the current `value` - prop; do not call `onChange`. -- Sync the draft from the `value` prop only while the input is **not focused** (a ref - flag set on focus/blur), so a debounced server round-trip can't overwrite live typing. -- Renders `type="number" inputMode="numeric"` with `min`/`max` attributes and the existing - `field` / `field__control` BEM classes; `label` renders as `field__label` when given. - No hardcoded `id`/`data-testid` — both are props (the bug that makes `VacationDaysInput` - non-reuse-safe today). - -Co-located test `NumberField.test.tsx` covering: clear-then-retype yields `5` not `05`; -empty draft fires no `onChange`; blur-empty restores the prior value; blur clamps to -`min`/`max`; external `value` change while focused does not clobber the draft. - -## 2. Remove the child-level estimate input - -`frontend/src/pages/Settings.tsx` - -- Delete the `VacationDaysInput` block (`:562-571`) and its import (`:21`). -- Delete the `preschoolPlanningDays` state (`:252`), its prefill in the `childDetails` - effect (`:285`), and `preschoolPlanningDaysPerYear` from the `updateChildDetails` call in - `handleSaveFamily` (`:366`). The field is optional in `ChildUpdate` - (`backend/app/models/child.py:40`), so omitting it leaves the stored value untouched. -- Pass `defaultPlannedDays={childDetails?.preschoolPlanningDaysPerYear ?? 6}` to - `PreschoolPlanningYearRow` (`:583`) instead of the removed state. -- Move the `settings.preschoolHelp` paragraph (`:563`) into the per-year block, under the - heading, and drop the now-redundant `settings.preschoolPerYearHelp` line or merge the two - into one sentence. -- Retitle the section so it names the subject: `settings.preschoolPerYearTitle` → - `"Preschool planning days"` / `"Förskolans planeringsdagar"`. - -`frontend/src/i18n/{en,sv}/settings.ts` — remove `settings.preschoolPlanningDaysLabel` -(`:83` in both), reword `settings.preschoolHelp` (`:82`) so it reads as the intro to the -year rows, and update `settings.preschoolPerYearTitle` (`:126`). - -Delete `frontend/src/components/inputs/VacationDaysInput.tsx` and -`VacationDaysInput.test.tsx` — `Settings.tsx:564` was the only usage. - -## 3. Roll `NumberField` out - -| File | Inputs | -|---|---| -| `frontend/src/pages/Settings.tsx:181-195` | per-year **Planned days** (the reported bug) | -| `frontend/src/pages/Settings.tsx:199-217` | per-year **Days taken** — keep its `null` = "not recorded" sentinel; it needs `value: number \| null` handling, so either give `NumberField` an optional `nullable` mode or leave this one as-is and note why | -| `frontend/src/components/vacation-days/VacationDaysTable.tsx:174-183, 188-197` | `totalDays`, `savedDaysIn` | -| `frontend/src/components/calendar/PeriodPopover.tsx:458-470` | `lastYear` (min 2000 / max 2200 — blanking currently shows `0`) | - -`frontend/src/components/modals/ParentBalanceForm.tsx` already handles empty correctly via -`parseField` (`:134`, `value={x ?? ''}`) — **leave it alone**, or migrate it only if -`NumberField` gains the nullable mode above. Do not regress its `null` semantics. - -Existing `data-testid`s (`preschool-planned-{year}`, `vacation-total-days-{year}`, -`period-last-year-input`, …) must be preserved so current tests keep targeting the same -elements. - -## 4. Tests to update - -- `frontend/src/pages/Settings.test.tsx:416-437` — drop the `vacation-days-field` prefill - and the "change to 10 → `updateChildDetails({ preschoolPlanningDaysPerYear: 10 })`" - assertions; assert instead that the family save no longer sends that key and that - year rows still prefill from `childDetails.preschoolPlanningDaysPerYear`. -- `frontend/src/pages/Settings.test.tsx:459-500` — add a clear-then-retype case on - `preschool-planned-{year}` asserting the committed value is `5`, and that clearing alone - does not call `setPlanned`. -- `frontend/src/hooks/useChildSettings.test.ts:100-113` — unchanged (the hook still - supports the field); no edit expected, verify it still passes. -- Backend tests unchanged: no model, service, entity or migration touched. - -Mock only with the typed factories in `frontend/tests/fixtures/` and `vi.mocked(fn)` — -no `as any`, no casts (CLAUDE.md anti-patterns). - -## Verification - -Delegate the full suites to a **Haiku** subagent, one at a time (never in parallel), with -raw verbatim output required: - -```bash -# from frontend/ -npm run typecheck -npm run lint # zero warnings -npm run coverage # gates: lines 96 | functions 91 | branches 92 | statements 96 -``` - -Backend is untouched, so a backend run is only needed if something unexpected changes there. - -Then a **Sonnet** subagent drives Chrome (never the coordinator) against `npm run dev`: - -1. Settings → Family panel: confirm the standalone "Preschool planning days per year" - input is gone and the year rows still show the seeded value with the "assumed" badge. -2. In a **Planned days** box: select-all, delete → box goes empty (not `0`); type `5` → - shows `5`, not `05`; blur → autosaves `5`. -3. Blank a box and click away without typing → the previous value returns, nothing saved. -4. Repeat 2-3 on a `VacationDaysTable` row and on the period `lastYear` input. -5. Check the reworded section in **both Swedish and English** — the labels must not clip - (per the bilingual-CSS rule). - -Finish with `graphify update .`. diff --git a/new-features/free-days-and-auto-planning-plan.md b/new-features/free-days-and-auto-planning-plan.md deleted file mode 100644 index 5686d59..0000000 --- a/new-features/free-days-and-auto-planning-plan.md +++ /dev/null @@ -1,102 +0,0 @@ -# Free days, vacation forfeiture, and auto-replanning - -## Context - -Three feedback points from `todo.md` (human-owned file — do not edit it; item 1 there, the yearly-repeat bug, is owned by another worker and is out of scope here): - -1. **A full-week selection must not burn days on Sat/Sun/red days.** You can never take *vacation* on a free day; you *can* take PL on one (more money, fewer days banked) — that must be an opt-in setting defaulting to off. -2. **Vacation days at risk of being forfeited should push the planner toward spending vacation** instead of PL days. -3. **Every event that changes the days off must trigger a re-plan** of the day *types*, not just a refetch. - -Exploration shows the backend already implements most of 1 and 2. The real gaps are: the hand-pin path bypasses the free-day invariant entirely, recalculation only fires from the Calendar page for some events, and vacation forfeiture is invisible in the UI. - -### What already exists (reuse, do not rebuild) - -| Piece | Where | State | -|---|---|---| -| `take_pl_on_red_days` workspace column + migration `003` + `PUT /api/workspace/:id` | `backend/app/entities/workspace.py`, `app/services/workspace_service.py` | done, defaults `False` | -| Settings toggle + sv/en strings | `frontend/src/pages/Settings.tsx:496`, `src/i18n/{sv,en}/settings.ts:108-110` | done | -| Allocator free-day handling (free day ⇒ `'free'`, or FP via `REASON_FP_RED_DAY` only when the flag is on; vacation **never**) | `allocation_service.py` `_fund_solo:1271`, `_fund_both:~1518` | done, tested at `tests/unit/test_services/test_allocation_service.py:2616+` | -| `is_free_day(d, holidays_map, overrides)` — the single definition | `allocation_service.py:470` | reuse everywhere | -| `vacation_forfeiting` bias in `_assign_tier3` | `allocation_service.py:1171-1225`, fed from `AnalyticsService._vacation_year_series` at `:2935` | done | -| `VacationYearBalance.expiring / expiring_age / expiring_over_cap` on the wire | `analytics_service.py:47`, `frontend/src/types/entities.ts:251` | computed, **never rendered** | -| `POST /api/plan/sync` (full re-plan + persist + diff) | `planning_service.py:662`, `usePlanSync`, `Calendar.tsx` `runSync:365` | done | -| `WorkspaceContext.planVersion` / `bumpPlanVersion()` cross-instance invalidation | `frontend/src/context/WorkspaceContext.tsx:25-36,179` | done; bumped only by `usePlannedDays`, `useDayOverrides`, `usePlannedDayExclusions` | - ---- - -## Task 1 — Free days must never consume vacation (hand-pin path) - -Selecting a range that spans Sat/Sun/red days is fine and stays fine. The planner already skips those days correctly. The hole is the **"advanced" day popover**: `Calendar.tsx` `handleSavePinnedDay:571` writes one `planned_leave_day` row per selected date, free days included, and `PlanningService.add_planned_day:241` accepts `day_type='vacation'` on a Saturday without complaint. - -**Backend (authoritative guard).** In `backend/app/services/planning_service.py`: -- Add optional `workspace_repo` + `day_override_repo` constructor deps, following the existing optional-dependency pattern of `_engine` / `_leave_wish_repo` (docstring the reason; keep `scripts/seed_demo.py` and test fixtures working unchanged when unset). -- New private helper `_reject_illegal_free_day(workspace_id, date, day_type)` used by both `add_planned_day` and `update`: build the holiday map + `DayOverrideService.get_effective_map(workspace_id)`, call `is_free_day` (import from `allocation_service`, do not re-derive), then raise `ValidationError` when - - `day_type == 'vacation'` and the day is free (never legal), or - - `day_type in ('sjukpenning','lagsta')` and the day is free and `workspace.take_pl_on_red_days` is `False`. -- Skip the check entirely when the deps are unset (`None`), so behaviour is byte-identical for callers that never wire them; `app/dependencies.py` wires the real ones. -- Error goes through `app/utils/errors.py`, message keyed so the frontend can translate. - -**Frontend.** In `Calendar.tsx` `handleSavePinnedDay`, filter the write loop by the day's `isFree` from `calendarDaysByDate` (already loaded, `CalendarDayEntry.isFree`): -- `vacation` → skip free days always. -- `sjukpenning`/`lagsta` → skip free days unless `workspace.takePlOnRedDays`. -- If every selected date was skipped, show the existing modal error rather than silently doing nothing. -- `PlannedDayModal` shows a one-line note when the selection contains free days that will be left as days off, with a pointer to the setting. New sv/en keys in `src/i18n/{sv,en}/calendar.ts`. - -**Tests.** Backend: `add_planned_day`/`update` reject vacation on a Saturday and on a red day; reject PL on a free day with the flag off; accept PL on a free day with it on. Frontend: pinning a Mon–Sun range with `vacation` creates 5 rows, not 7; with `takePlOnRedDays: true` and `sjukpenning`, creates 7. - ---- - -## Task 2 — Surface vacation days at risk - -The numbers are already on the wire and unused. - -- `frontend/src/components/vacation-days/VacationDaysTable.tsx` — add an "at risk" column rendering `VacationYearBalance.expiring`, with a danger style when `> 0` and a tooltip/help line splitting `expiringAge` (too old to carry) from `expiringOverCap` (above the carry-forward ceiling). Follow `Age4Bars.tsx` / `KeyFigures.tsx` for the existing danger-ink pattern; size text by content per the bilingual CSS rule. -- `frontend/src/components/dashboard/KeyFigures.tsx` — a tile mirroring the existing `dashboard-figure-expiring` for vacation, so the reason the plan shifted toward vacation days is visible. -- `PlanWarning` kind `'vacation_expiring'` already exists; confirm it renders where `days_lost_at_age_4` does and add the missing sv/en strings if not. -- Tests: table shows the column and the danger state; zero-expiring renders the neutral state. - -No backend change — `_vacation_year_series` and the `_assign_tier3` bias stay as they are. - ---- - -## Task 3 — Re-plan on every plan-input change (global) - -Today `runSync` is Calendar-only and fires once per workspace on mount plus after wish/preset/exclusion edits. Pinned-day saves and deletes, and every Settings change, do not re-plan. - -**Move the trigger into the workspace layer** so it is route-independent: - -- New `frontend/src/context/PlanSyncProvider.tsx` (mounted inside `WorkspaceProvider`, or folded into it — implementer's call, keep `WorkspaceContext`'s surface stable): a `useEffect` on `[workspaceId, planVersion]` that calls `api.syncPlan(workspaceId)` **silently**, guarded by an in-flight ref so overlapping bumps coalesce into one trailing run, and by a "skip the very first render if Calendar is about to sync anyway" check is *not* needed — a duplicate silent sync is idempotent and cheap, but the in-flight guard must still prevent a stampede. - - Cycle safety: `bumpPlanVersion` is never called from a fetch effect (`usePlannedDays.ts:84-93` documents this), and `syncPlan` does not bump. Verified no loop. - - On an `eviction_preview` coming back (nothing written, needs a user choice), the silent path must **not** discard it — surface it through context so Calendar can prompt on next visit, or at minimum leave the plan untouched and log through `src/services/errors.ts`. Never swallow it into an empty catch. -- **Add the missing bumps** so every plan input participates. `useVacationDays.upsert`, `usePreschoolPlanningDays` (after its 800 ms debounce settles), `useChildSettings.update`, `api.updateParentSettings`' caller in `Settings.tsx`, and `handleToggleTakePlOnRedDays` all call `bumpPlanVersion()` on success — they currently do not. `usePlannedDays`, `useDayOverrides`, `usePlannedDayExclusions` already do. -- **Calendar keeps its explicit `runSync` calls** for the paths that need a toast + undo (wish create/edit/delete, presets, exclusions). Those write via `useLeaveWishes`, which does *not* bump, so there is no double sync. Replace the `didAutoSyncRef` mount-once effect with reliance on the provider, or keep it — it becomes redundant but harmless; prefer removing it to keep one trigger. -- `handleSavePinnedDay` / `handleDeletePlannedDay` need no new call: they write through `usePlannedDays`, which bumps, which now drives the global sync. This is what makes item 2's forfeiture bias actually retype existing unpinned days after a calendar edit. - -**Known limit to state plainly in the UI, not silently:** a *pinned* day is excluded from re-planning by design (`sync_plan` passes `only_pinned_existing=True`). The forfeiture bias can therefore never retype a hand-pinned day. The popover note from Task 1 is the right place to say pinning opts that day out of optimization. - -**Tests.** Frontend: a `bumpPlanVersion()` from a non-Calendar hook triggers exactly one `api.syncPlan`; two rapid bumps coalesce to one trailing call; a failing sync does not wedge the in-flight ref. - ---- - -## Verification - -Delegate all full-suite runs to a **Haiku** subagent (per AGENTS.md), serialized — one suite at a time, never parallel: - -```bash -# backend/ -uv run ruff check . && uv run ruff format --check . && uv run pytest -# frontend/ -npm run typecheck && npm run lint && npm run coverage -``` -Require raw verbatim output with exact pass/fail counts and coverage percentages; gates are backend `--cov-fail-under=80` and frontend lines 96 / functions 91 / branches 92 / statements 96. Never lower a gate. - -New migration is **not** needed — no schema change. - -End-to-end, delegated to a **Sonnet** subagent driving Chrome (never the coordinator), on `npm run dev`, checked in both Swedish and English: -1. Select Mon–Sun in the calendar, "advanced" → vacation. Assert 5 planned days, Sat/Sun untouched. -2. Settings → tick "Ta ut föräldrapenning på röda dagar" → return to calendar, repeat with `sjukpenning`. Assert 7 days. -3. Settings → edit a vacation-days row → assert a background `POST /api/plan/sync` fires (network panel) without visiting the Calendar page. -4. Enter a vacation balance that forfeits days → assert the at-risk column shows it and the plan's day mix shifts toward vacation. - -Then `graphify update .`. diff --git a/todo.md b/todo.md index 73bf248..231a3ea 100644 --- a/todo.md +++ b/todo.md @@ -1,42 +1,12 @@ ## NOT ALLOWED TO TOUCH THIS; ONLY HUMANS CAN EDIT THIS FILE -- Bug in the planning when it repeats yearly: It is taking the exact same dates, but the way you usually take time off is week based, so 5th-11th of october 2026 is a monday through sunday, but for 2027 it looks like I'll take time off from tuesday to the following week's monday, which is not what I will do. Instead for 2027 I will probably take 4th to 10th instead. - -> DONE - - -Following three have a plan (free-days-and-auto-planning-plan.md): - - Please just verify that if I say that I take a full week time-off, including saturday and sunday, that I will NOT actually take any vacation or PL during those days. Or rather: This should be a setting: Take PL during red days yes/no. And it should default to NO. It is impossible to take vacation during red days (and saturdays), but you can actually take PL to get more money, but you will of course loose days. - - - If we have vacation days that are in risk of being lost, then the planner should run to update the kind of days used in the plan to use more vacation days (thus saving PL days). - - - On every event that updates the days off (i.e. the calendar is making some changes), that should trigger the planner to recalculate the best kinds of time-off for all days. - - -> In progress, super long time..... - - -- Problem with the yearly timelines in general tab - -> alignment-plan.md -> DONE - ## Below here is taken from another markdown file, so should probaby be filtered and removed + ## High prio -- If we have vacation days that are in risk of being lost, then the planner should run to update the kind of days used in the plan to use more vacation days (thus saving PL days). --> have a plan I think - -- On every event that updates the days off (i.e. the calendar is making some changes), that should trigger the planner to recalculate the best kinds of time-off for all days. --> have a plan I think - -- Use fabel to go over the frontend to analyse what can be extracted into proper components and reused that today might be implemented multiple times: "Component Extraction" - -## medium prio -- Update all deprecated things in the backend code. --> fix-deprecations-backend-plan.md -> DONE - ## lowest prio -- check if the security audit findings have been done --> yet to have a plan +- Also noted, not fixed: WindowOutcome in frontend/src/services/api.ts is missing requested\_strategy, strategy\_degraded, +dubbeldagar\_legally\_available, and RawPlanAge4Projection is missing savable\_cap\_at\_b4 — all sent by the backend today. +There's no frontend counterpart to the parity test, so they fell behind silently. -- Also noted, not fixed: WindowOutcome in frontend/src/services/api.ts is missing requested_strategy, strategy_degraded, - dubbeldagar_legally_available, and RawPlanAge4Projection is missing savable_cap_at_b4 — all sent by the backend today. - There's no frontend counterpart to the parity test, so they fell behind silently. \ No newline at end of file