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 `