From 0081f60b06425690b819bfe8052224aa315509e9 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 20 Sep 2026 07:00:22 +0200 Subject: [PATCH] Finish the redesign's edges: auth page, modals, dead code, docs The sign-in page becomes a plain centred column with the brand mark and a segmented login/register switch, modals lose their last inner boxes, the red-days list collapses every year but the current one, and the retired components' CSS, an unused list component and 23 unreferenced translation keys are removed. ARCHITECTURE.md documents the Almanacka styling conventions and the per-year balance rule. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H --- ARCHITECTURE.md | 57 +++ .../calendar/CalendarExceptions.tsx | 98 ++--- .../calendar/calendar-exceptions.css | 165 +++++++- .../modals/ParentBalanceForm.test.tsx | 5 +- .../components/modals/ParentBalanceForm.tsx | 2 + .../components/modals/parent-balance-form.css | 4 +- .../src/components/modals/qrcode-modal.css | 4 - .../vacation-days/VacationDaysList.test.tsx | 132 ------- .../vacation-days/VacationDaysList.tsx | 45 --- frontend/src/hooks/useLatestBalance.ts | 7 +- frontend/src/i18n/en/auth.ts | 1 - frontend/src/i18n/en/balance.ts | 1 - frontend/src/i18n/en/calendar.ts | 12 - frontend/src/i18n/en/common.ts | 4 - frontend/src/i18n/en/dashboard.ts | 1 - frontend/src/i18n/en/settings.ts | 9 +- frontend/src/i18n/sv/auth.ts | 1 - frontend/src/i18n/sv/balance.ts | 1 - frontend/src/i18n/sv/calendar.ts | 12 - frontend/src/i18n/sv/common.ts | 4 - frontend/src/i18n/sv/dashboard.ts | 1 - frontend/src/i18n/sv/settings.ts | 9 +- frontend/src/pages/Auth.tsx | 18 +- frontend/src/pages/auth.css | 60 +-- frontend/src/styles/components.css | 368 ------------------ frontend/src/styles/responsive.css | 27 -- frontend/src/utils/leaveTypes.test.ts | 14 +- frontend/src/utils/leaveTypes.ts | 9 - 28 files changed, 326 insertions(+), 745 deletions(-) delete mode 100644 frontend/src/components/vacation-days/VacationDaysList.test.tsx delete mode 100644 frontend/src/components/vacation-days/VacationDaysList.tsx diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7d0b0cb..21dd9a4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -89,6 +89,40 @@ end-to-end specs, run separately from Vitest unit tests. Run `git ls-files frontend/src` for the current, authoritative file list. +#### 2.2.1 Styling — the Almanacka design system + +All design tokens (colour, spacing, type scale, radii, shadow) live as CSS custom properties in +`src/styles/global.css` only — a style guard (`src/styles/styles.test.ts`) fails the build on any +hex/rgba, px font-size, or `@media` query found outside `global.css`/`responsive.css`. Everything +else composes those tokens: `src/styles/primitives.css` holds cross-page primitives (`.btn`, +`.field`, `.card`, `.modal__*`, `.segmented`, `.disclosure`, `.skeleton`, `.alert`, `.badge`), and +each component/page owns a co-located `*.css` file for its own layout. + +One accent (ink-blue) drives CTAs, focus rings, the active nav item, "today" and selection — there +is no second "success" colour. Type is self-hosted Schibsted Grotesk (`public/fonts/`, woff2, no +Google Fonts request — GDPR), scale 12/14/16/20/28/40 with nothing below 12px. The calendar ("the +Almanacka") tells day states apart by numeral ink and a small badge letter rather than cell fill — +red day → danger ink + "R", klämdag → warning ink + "K", today → filled accent circle — and tells +leave types apart by fill/ink/outline (vacation = accent tint, sjukpenning = dark fill, lägsta = +white + hatched outline), never by hue alone. Icons come from a single `Icon` component (inline SVG +symbols) — no emoji, no icon font. See `captures/redesign/design-system.md` for the full token +table and invariants (gitignored — a design scratch doc, not part of the shipped app). + +Two recurring structural primitives replace what used to be ad hoc boxes: `.disclosure` (a +`
`/`` row with a hairline, not a bordered card — used by the dashboard's "Fler +detaljer", each dashboard year row, and Settings' per-year red-days list) and `.segmented` (a +pill-style two-or-more-option toggle — the SV/EN `LanguageToggle`, the auth page's Log in/Create +account tabs). Loading states go through `.skeleton` shimmer blocks rendered by a shared +`PageSkeleton` component (`src/components/common/`), which each page (`Dashboard`, `Calendar`, +`Settings`) renders inside its own `` while its first fetch is in flight, so every route +gets the same loading treatment for free instead of each page inventing its own spinner. + +Parental-leave balance entry has exactly one home: `BalanceFlow` (`src/components/balance/`), used +from Settings' "Saldon" panel — the dashboard no longer has its own balance-entry UI, only a read +of the latest saved figures. `BalanceFlow` wraps `ParentBalanceForm` (the modal that actually asks +for sjukpenningnivå/lägstanivå/dubbeldagar left) with the per-member status row (source + +freshness) that used to be duplicated per page. + --- ### 2.3 Data Acquisition: Scraping (primary) + Manual Fallback @@ -797,6 +831,29 @@ The dashboard's "projection" endpoint (`/api/analytics/{workspace_id}/projection Edge cases the calculation must handle: child not yet born (plan from due date), reserved days (the 90 non-transferable days are part of each parent's pool and also subject to the age-4 cap), dubbeldagar (each counts as a day against the using parent's pool), and leap-year / month-arithmetic for `birth_date + 4 years`. +### 6.6 Per-year balance series (the analytics overview's year-by-year answer) + +`Age4Projection.year_balances` (`ParentYearBalance`, `backend/app/services/analytics_service.py`) +gives the overview a vacation + parental-leave balance **per parent, per calendar year**, birth +year through the 4th-birthday year, instead of only the single age-4 total. The vacation half +(`AnalyticsService._vacation_year_series`) models Semesterlagen's carry-forward rule as a FIFO bank +of `(year_earned, count)` entries: + +- Each year, planned use is drawn oldest-entry-first, then from that year's own entitlement (never + itself banked); at least `min_vacation_days_per_year` (the **floor**, default 20) must be taken + from the current year's own days, not the bank. +- At year end, only days earned *above* the floor (plus the surviving bank balance) carry forward, + and each carried-forward day **expires after 5 years** (`LeaveRules.vacation_carry_forward_years`) + — an entry still unconsumed in its final valid year drops out and is reported as `expiring` for + that year instead of carrying on. +- `saved_days_in` on a `vacation_days` row normally only **seeds** the bank for the series' first + year (no earlier history to derive it from). On any later year, a non-zero `saved_days_in` is + instead a Settings-UI **override**: it replaces the computed carry-in outright (resetting the bank + to one entry dated year − 1) rather than adding to it. A zero/absent value never overrides. + +The frontend renders this as one row per year (`YearRows.tsx`, a `.disclosure` per year) on the +Dashboard, each showing carried-in/earned/used/carried-out per parent. + --- ## 7. Frontend Type Safety diff --git a/frontend/src/components/calendar/CalendarExceptions.tsx b/frontend/src/components/calendar/CalendarExceptions.tsx index 0d12c17..b14f7b7 100644 --- a/frontend/src/components/calendar/CalendarExceptions.tsx +++ b/frontend/src/components/calendar/CalendarExceptions.tsx @@ -58,6 +58,7 @@ export function CalendarExceptions({ }: CalendarExceptionsProps): ReactNode { const { t, locale } = useTranslation() const endYear = endYearProp ?? startYear + years - 1 + const currentYear = new Date().getFullYear() const [pendingDate, setPendingDate] = useState(null) const [toggleError, setToggleError] = useState(null) @@ -179,51 +180,60 @@ export function CalendarExceptions({ {!isLoading && !error && Array.from(byYear.entries()).map(([year, items]) => ( -
-

{year}

- {items.length === 0 ? ( -

{t('redDaysList.emptyYear')}

- ) : ( -
    - {items.map((day) => { - const override = overrideByDate.get(day.date) - return ( -
  • - {formatDateForDisplay(day.date, locale)} - {day.dayType === 'red_day' && day.name && ( - {formatHolidayName(day.name)} - )} - {day.isOverride && ( - + + {year} + +
    + {items.length === 0 ? ( +

    {t('redDaysList.emptyYear')}

    + ) : ( +
      + {items.map((day) => { + const override = overrideByDate.get(day.date) + return ( +
    • + {formatDateForDisplay(day.date, locale)} + {day.dayType === 'red_day' && day.name && ( + {formatHolidayName(day.name)} + )} + {day.isOverride && ( + + {day.overrideIsWorkingDay ? t('redDaysList.overrideWorking') : t('redDaysList.overrideFree')} + + )} + {override?.note && ( + {t('dayOverridesInput.noteLabel', { note: override.note })} + )} + -
    • - ) - })} -
    - )} -
    + {day.isFree ? t('redDaysList.effectiveFree') : t('redDaysList.effectiveWorking')} + +
  • + ) + })} +
+ )} +
+
))} diff --git a/frontend/src/components/calendar/calendar-exceptions.css b/frontend/src/components/calendar/calendar-exceptions.css index 13abf45..9664160 100644 --- a/frontend/src/components/calendar/calendar-exceptions.css +++ b/frontend/src/components/calendar/calendar-exceptions.css @@ -1,11 +1,8 @@ /* CalendarExceptions — merges the old RedDaysList (per-year red days + - * overrides) with DayOverridesInput's add-a-date form. Most row/list/form - * styling is already carried over unchanged from components.css under the - * same `.red-days-list__*` / `.day-overrides-input__*` class names (both - * components used them before being folded into this one); this file only - * adds what's new here — spacing between the two merged blocks and the two - * override-badge/effective-state variants the old components never needed - * together in the same list. */ + * overrides, each year now a `.disclosure`) with DayOverridesInput's + * add-a-date form. All `.red-days-list__*` / `.day-overrides-input__*` + * rules live here (moved from components.css once this became the only + * consumer of those class names). */ .calendar-exceptions__help { font-size: var(--fs-sm); color: var(--c-text-muted); @@ -39,3 +36,157 @@ color: var(--c-accent); border-color: var(--c-accent-weak); } + +/* Per-year list — moved from components.css when the plain + * `.red-days-list__year` div became a `.disclosure` (only the current year + * open by default; see CalendarExceptions.tsx). */ +.red-days-list__pagination { + display: flex; + align-items: center; + gap: var(--sp-3); + margin-bottom: var(--sp-4); +} + +.red-days-list__range { + font-weight: 600; +} + +.red-days-list__year-summary { + font-size: var(--fs-lg); +} + +.red-days-list__empty { + color: var(--c-text-muted); + font-size: var(--fs-sm); +} + +.red-days-list__items { + list-style: none; + padding: 0; + margin: 0; +} + +.red-days-list__item { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-2) 0; + border-bottom: 1px solid var(--c-border); + font-size: var(--fs-sm); +} + +.red-days-list__date { + font-weight: 600; + min-width: 160px; +} + +.red-days-list__name { + color: var(--c-text); +} + +/* `.red-days-list__effective` is a real ` -

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

void handleSubmit(e)}> {isRegister && (
@@ -115,7 +119,7 @@ export function Auth() { />
{error && ( -

+

{error}

)} diff --git a/frontend/src/pages/auth.css b/frontend/src/pages/auth.css index 70a6764..93fc834 100644 --- a/frontend/src/pages/auth.css +++ b/frontend/src/pages/auth.css @@ -1,8 +1,9 @@ /* Login/register — the app's front door, rendered outside (no - * header yet, there's no session). Previously a bare, unstyled
that - * relied entirely on the global element resets; this gives it the same - * centered-card treatment as the onboarding wizard it leads into. */ + * header yet, there's no session). A plain centred column, not a card on + * grey: the page background carries straight through, matching the rest + * of the almanac's whitespace-over-boxes language. */ .auth-page { + position: relative; min-height: 100vh; display: flex; align-items: center; @@ -10,49 +11,56 @@ padding: var(--sp-5); } -.auth-page__card { - width: 100%; - max-width: 380px; +.auth-page__lang { + position: absolute; + top: var(--sp-5); + right: var(--sp-5); } -.auth-page__brand { - margin: 0 0 var(--sp-4) 0; - font-size: var(--fs-sm); +.auth-page__column { + width: 100%; + max-width: 380px; + display: flex; + flex-direction: column; + align-items: center; +} + +.auth-page__mark { + width: 32px; + height: 32px; + border-radius: 8px; + margin-bottom: var(--sp-4); +} + +.auth-page__title { + font-size: var(--fs-xl); text-align: center; + margin: 0 0 var(--sp-5) 0; } .auth-page__tabs { display: flex; - gap: var(--sp-2); - margin-bottom: var(--sp-4); + width: 100%; + margin-bottom: var(--sp-5); } .auth-page__tab { flex: 1; - justify-content: center; -} - -/* Active tab — same "selected" formula as `.choice-card[aria-checked='true']` - * in primitives.css (accent-weak fill, accent text) so a pressed tab reads - * as "this is the current mode" without inventing another color pairing. */ -.auth-page__tab[aria-pressed='true'] { - background-color: var(--c-accent-weak); - color: var(--c-accent); -} - -.auth-page__title { - text-align: center; - margin-bottom: var(--sp-5); } .auth-page__form { + align-self: stretch; display: flex; flex-direction: column; gap: var(--sp-3); - margin-bottom: var(--sp-4); } .auth-page__form button[type='submit'] { margin-top: var(--sp-2); } +.auth-page__error { + margin: 0; + font-size: var(--fs-sm); + color: var(--c-danger); +} diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index c638ef1..2def8d3 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -635,134 +635,10 @@ min-height: 100px; } -/* Input Grid */ -.form-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: var(--sp-4); -} - -.form-grid .form-group { - margin-bottom: 0; -} - -.form-grid .form-group input, -.form-grid .form-group select { - max-width: none; -} - .settings-page > .alert { margin-bottom: var(--sp-4); } -.settings-page__help { - font-size: var(--fs-sm); - color: var(--c-text-muted); - margin: 0 0 var(--sp-4) 0; - max-width: 60ch; -} - -.settings-page__field { - margin-bottom: var(--sp-4); -} - -.settings-page__label { - font-size: var(--fs-base); -} - -.settings-page__select { - max-width: 320px; -} - -.settings-page__checkbox-label { - display: flex; - align-items: center; - gap: var(--sp-2); - cursor: pointer; -} - -.settings-page__partner-row { - display: flex; - gap: var(--sp-3); -} - -/* Box/border/focus chrome comes from `.field__control` in primitives.css — - * this only adds the flex sizing `.field__control`'s own rule doesn't know - * about (it sits in an inline row next to a button, not a stacked `.field`). */ -.settings-page__partner-input { - flex: 1; -} - -.settings-page__member-list { - list-style: none; - margin: 0 0 var(--sp-4) 0; - padding: 0; - display: flex; - flex-direction: column; - gap: var(--sp-2); -} - -.settings-page__member-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--sp-3); - padding: var(--sp-2) 0; -} - -.settings-page__remove-confirm { - display: flex; - align-items: center; - gap: var(--sp-2); - color: var(--c-danger); - font-size: var(--fs-sm); -} - -/* Vacation Days Table (multi-year) */ -.vacation-days-table { - width: 100%; - border-collapse: collapse; - margin: 0 0 var(--sp-4) 0; -} - -.vacation-days-table th { - padding: var(--sp-3) var(--sp-4); - background-color: var(--c-bg); - border-bottom: 2px solid var(--c-border); - font-weight: 600; - color: var(--c-text-muted); - text-align: left; - font-size: var(--fs-sm); -} - -.vacation-days-table td { - padding: var(--sp-3) var(--sp-4); - border-bottom: 1px solid var(--c-border); - font-size: var(--fs-base); -} - -.vacation-days-table tbody tr:last-child td { - border-bottom: none; -} - -.vacation-days-table__year { - font-weight: 600; -} - -.vacation-days-table__assumed-badge { - margin-left: var(--sp-2); - cursor: help; -} - -.vacation-days-table__input { - width: 90px; -} - -.vacation-days-table__empty { - color: var(--c-text-muted); - font-size: var(--fs-sm); -} - /* Fieldsets */ fieldset { border: 1px solid var(--c-border); @@ -812,247 +688,3 @@ legend { color: var(--c-text-muted); margin-top: var(--sp-2); } - -/* Day Overrides Input */ -.day-overrides-input { - margin-bottom: var(--sp-5); -} - -.day-overrides-input__empty { - padding: var(--sp-5); - background-color: var(--c-bg); - border-radius: var(--radius); - border: 1px solid var(--c-border); - color: var(--c-text-muted); - text-align: center; - margin-bottom: var(--sp-5); -} - -.day-overrides-input__empty p { - margin: 0; -} - -.day-overrides-input__list { - margin-bottom: var(--sp-5); -} - -.day-overrides-input__items { - list-style: none; - padding: 0; - margin: 0; - display: flex; - flex-direction: column; - gap: var(--sp-3); -} - -.day-overrides-input__item { - padding: var(--sp-4); - background-color: var(--c-bg); - border: 1px solid var(--c-border); - border-radius: var(--radius); -} - -.day-overrides-input__item-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: var(--sp-3); - margin-bottom: var(--sp-3); -} - -.day-overrides-input__item-date { - flex: 0 0 auto; - font-weight: 500; -} - -.day-overrides-input__item-date strong { - color: var(--c-text); -} - -.day-overrides-input__item-status { - flex: 1; - text-align: right; -} - -.day-overrides-input__status { - display: inline-block; - padding: var(--sp-2) var(--sp-3); - border-radius: var(--radius); - font-size: var(--fs-sm); - font-weight: 500; -} - -.day-overrides-input__status--working { - background-color: var(--c-warning-weak); - color: var(--c-warning); -} - -.day-overrides-input__status--off { - background-color: var(--c-success-weak); - color: var(--c-success); -} - -.day-overrides-input__item-note { - font-size: var(--fs-sm); - color: var(--c-text-muted); - margin-bottom: var(--sp-3); -} - -/* Plain section, not a bordered card — matches the rest of Settings (see - * layout.css's "sections separate by whitespace and a hairline rule, not a - * stack of bordered/shadowed cards" comment). Only a top hairline separates - * it from the list of existing exceptions above. */ -.day-overrides-input__form { - padding-top: var(--sp-4); - border-top: 1px solid var(--c-border); -} - -.day-overrides-input__form h3 { - margin: 0 0 var(--sp-4) 0; - font-size: var(--fs-lg); - color: var(--c-text); -} - -.day-overrides-input__form-group { - max-width: 400px; - margin-bottom: var(--sp-4); -} - -.day-overrides-input__radio-group { - border: none; - padding: 0; - margin: 0; -} - -.day-overrides-input__radio-group legend { - padding: 0; - margin: 0 0 var(--sp-2) 0; -} - -.day-overrides-input__radio-label { - display: flex; - align-items: center; - gap: var(--sp-2); - margin-bottom: var(--sp-2); - cursor: pointer; -} - -.day-overrides-input__radio-label:last-child { - margin-bottom: 0; -} - -.day-overrides-input__radio-label input[type='radio'] { - width: auto; - margin: 0; -} - -.day-overrides-input__char-count { - display: block; - margin-top: var(--sp-1); - font-size: var(--fs-sm); - color: var(--c-text-muted); -} - - -/* Red Days List (§3.8 — statutory red days + workspace overrides, per year) */ -.red-days-list__pagination { - display: flex; - align-items: center; - gap: var(--sp-3); - margin-bottom: var(--sp-4); -} - -.red-days-list__range { - font-weight: 600; -} - -.red-days-list__year { - margin-bottom: var(--sp-5); -} - -.red-days-list__year-title { - font-size: var(--fs-lg); - margin: 0 0 var(--sp-2) 0; -} - -.red-days-list__empty { - color: var(--c-text-muted); - font-size: var(--fs-sm); -} - -.red-days-list__items { - list-style: none; - padding: 0; - margin: 0; -} - -.red-days-list__item { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--sp-3); - padding: var(--sp-2) 0; - border-bottom: 1px solid var(--c-border); - font-size: var(--fs-sm); -} - -.red-days-list__date { - font-weight: 600; - min-width: 160px; -} - -.red-days-list__name { - color: var(--c-text); -} - -/* `.red-days-list__effective` is a real `