diff --git a/DASHBOARD_EMPTY_DATA_TODO.md b/DASHBOARD_EMPTY_DATA_TODO.md new file mode 100644 index 0000000..94ccb9a --- /dev/null +++ b/DASHBOARD_EMPTY_DATA_TODO.md @@ -0,0 +1,80 @@ +# TODO — Dashboard/Calendar show no data (snake_case ↔ camelCase wire mismatch) + +**Status:** open · **Area:** frontend · **Owner:** _unassigned_ · **Filed:** 2026-06-14 + +## Symptom + +The Dashboard renders but the "Översikt" metrics, the parental-leave breakdown, and the +planned-days breakdown are all empty/zero, even though `parental_leave_fetch` has valid +rows and the API returns data. + +## Two things this is NOT + +- **Not a "latest scrape" problem.** The backend already selects the newest snapshot: + `get_latest_by_parent` (and `AnalyticsService`) use `ORDER BY fetched_at DESC LIMIT 1`. + The newest row (`fetched_at 2026-06-14T17:09:47`, the one with `total=240/used=76/...`) + is the one used; the older zero/negative rows are correctly ignored. No backend change. +- **Not (only) the planned-days 405.** That 405 (see `PLANNED_DAYS_405_TODO.md`) blocks the + planned-days list, but the empty *metrics* and *parental-leave breakdown* come from + separate endpoints that DO return 200. + +## Root cause + +The backend response models serialize **snake_case** (`combined_days_used`, `days_used`, +`day_type`, `start_date`, `tier_data`, …), but the frontend entity types and components read +**camelCase** (`combinedDaysUsed`, `daysUsed`, `dayType`, …). `src/services/api.ts`'s +`apiFetch` returns the raw JSON with no conversion, so every camelCase field is `undefined` +and renders as `0`/blank via the `?? 0` fallbacks. + +Examples: +- `Dashboard.tsx` reads `analytics.combinedDaysUsed`; backend sends `combined_days_used`. +- `Dashboard.tsx` filters `plannedDays.filter(d => d.dayType === 'vacation')`; backend sends `day_type`. +- `ParentalLeaveBreakdown` reads camelCase off `ParentalLeaveFetch`; backend sends snake_case. + +**Why tests didn't catch it:** the hook/component tests `vi.mock('@/services/api')` and return +camelCase fixtures, so they mock *past* the missing conversion layer. + +## Decision + +Keep the backend wire format **snake_case**; do the snake→camel mapping in the **frontend +`api.ts`**. This matches existing convention — `api.ts` already does the reverse (camel→snake) +for request bodies (e.g. `register` sends `display_name` from `req.displayName`; +`upsertVacationDays` sends `personal_data_uuid` from `req.personalDataUuid`). Response mappers +just complete the pattern and leave the working snake_case endpoints (`getUserContext`, +`TokenResponse`) untouched. + +## Fix + +In `frontend/src/services/api.ts`, for each function that returns a camelCase entity, add a +typed `RawX` wire shape and a `toX(raw): X` mapper, then map the `apiFetch` result before +returning. **Type-safe only** — `@typescript-eslint/no-explicit-any` is a hard CI error and +`npm run lint` runs with `--max-warnings 0`, so no `as any` / `@ts-ignore`. + +Functions to cover: +- `getWorkspaceOverview` — incl. nested `parent_projections` → `parentProjections` (each `Age4Projection`). +- `getParentalLeave` — incl. nested `tier_data` → `tierData` (the `TierData` shape). +- `getPlannedDays`, `createPlannedDay`, `updatePlannedDay` — `day_type`→`dayType`, `is_dubbeldag`→`isDubbeldag`, etc. +- `getBreakPeriods`, `createBreakPeriod` — `start_date`→`startDate`, `end_date`→`endDate`, `target_weeks`→`targetWeeks`. +- `getWorkspace`, `getVacationDaysByWorkspace` — for consistency (same mismatch latent there). + +Do **not** change outbound request bodies, and leave `getUserContext` / `TokenResponse` +as-is (already consumed snake_case in `WorkspaceContext`/`AuthContext`). + +## Verification + +From `frontend/`: + +```bash +npx tsc --noEmit && npm run lint && npm run coverage # CI gate: 90% coverage +``` + +Add at least one test that drives a **real** mapper at the `fetch` boundary (mock global +`fetch` to return a snake_case body, call `api.getWorkspaceOverview(...)`, assert the result +is camelCase). The existing `vi.mock('@/services/api')` tests cannot catch this class of bug. + +Manual: Dashboard metrics + parental-leave breakdown populate; planned-days breakdown counts +are correct once the 405 fix lands. + +## Related + +Depends on `PLANNED_DAYS_405_TODO.md` for the planned-days list to return 200 in the first place. diff --git a/PLANNED_DAYS_405_TODO.md b/PLANNED_DAYS_405_TODO.md new file mode 100644 index 0000000..e4dd019 --- /dev/null +++ b/PLANNED_DAYS_405_TODO.md @@ -0,0 +1,82 @@ +# TODO — Fix `GET /api/planned-days` 405 (planned-days router is incomplete) + +**Status:** open · **Area:** backend · **Owner:** _unassigned_ · **Filed:** 2026-06-14 + +## Symptom + +Loading the Dashboard and adding a day in the Calendar both log: + +``` +"GET /api/planned-days?child_id=3b9b6dad-... HTTP/1.1" 405 Method Not Allowed +``` + +Adding a day works (`POST /api/planned-days` → 201), but the follow-up refresh +(`GET /api/planned-days?child_id=…`) is rejected, so the calendar/dashboard never +show planned days. + +## Root cause + +`backend/app/routers/planned_days.py` defines `POST ""` but **no `GET ""`**. FastAPI +returns *405* (not 404) because the path exists for another verb. The list-by-child +endpoint was never finished — the `GET /range` handler (lines ~65–78) is a dead stub +(`pass` body, `= None` defaults on its `service`/`_current_user` deps) and `/range` +also collides with `GET /{planned_leave_day_id}`. + +Same router has a second gap: `api.updatePlannedDay` issues `PUT /api/planned-days/{id}` +(used by Calendar edit, `frontend/src/pages/Calendar.tsx:99`), but there is **no PUT +handler** and no `PlanningService.update`. Editing an existing day will 405 once adding works. + +## Fix + +In `backend/app/services/planning_service.py`: + +1. Add `list_by_child`: + ```python + async def list_by_child(self, child_id: UUID) -> list[PlannedLeaveDay]: + return await self._repo.list(child_id=child_id) + ``` + (The SQLite `list()` already filters on `child_id` alone.) +2. Add `update(self, planned_leave_day_id: UUID, day_type: str | None, is_dubbeldag: bool | None) -> PlannedLeaveDay` + — load via `get_by_id` (raise `NotFoundError` if missing), apply provided fields, call `repo.update`. + +In `backend/app/routers/planned_days.py`: + +3. Add the list route **before** `GET /{planned_leave_day_id}`: + ```python + @router.get("", response_model=list[PlannedLeaveDayResponse]) + async def list_planned_days( + service: PlanningServiceDep, + _current_user: CurrentUserDep, + child_id: UUID = Query(...), + ) -> list[PlannedLeaveDayResponse]: + return [_to_response(d) for d in await service.list_by_child(child_id)] + ``` +4. Delete the dead `GET /range` stub (lines ~65–78). +5. Add the `PUT /{planned_leave_day_id}` handler using the existing + `PlannedLeaveDayUpdate` model → `service.update(...)` → `_to_response`. + +> Route ordering matters: declare `""`, `/workspace/...`, then `/{planned_leave_day_id}` +> last, so the UUID catch-all doesn't swallow the others. + +## Verification + +From `backend/`: + +```bash +uv run ruff check . && uv run ruff format --check . && uv run pytest +``` + +Add tests: +- `GET /api/planned-days?child_id=…` → 200 with the seeded rows for that child. +- `PUT /api/planned-days/{id}` → 200, fields updated; unknown id → 404. + +Manual: add a vacation day in the Calendar → `POST 201` then `GET 200` and the list +refreshes; edit an existing day → `PUT` succeeds. + +After code changes: `graphify update .` + +## Related + +The 405 only blocks the *planned-days* list. The dashboard's empty **metrics / +parental-leave breakdown** is a separate snake_case↔camelCase issue — +see `DASHBOARD_EMPTY_DATA_TODO.md`. diff --git a/SCRAPER_ZERO_DAYS_FIX_PLAN.md b/SCRAPER_ZERO_DAYS_FIX_PLAN.md new file mode 100644 index 0000000..66c446f --- /dev/null +++ b/SCRAPER_ZERO_DAYS_FIX_PLAN.md @@ -0,0 +1,167 @@ +# FK Scraper — "zero days" bug fix plan + +**Status:** Ready for implementation · **Difficulty:** Junior-friendly (one senior-flagged optional item) +**File to edit:** `backend/app/scrapers/fk_scraper.py` +**Tests:** `backend/tests/unit/test_fk_scraper.py` + +--- + +## TL;DR + +The scraper navigates correctly, clicks *Ansök för …*, and reaches the `#/valjdagar` +page. The bug is **a race condition on that final page**: the +`[data-test="stepDagarKvar"]` panel renders its skeleton with **placeholder zeros +first**, then fills in the real numbers after an async data fetch. The scraper reads +the moment the container exists — so it grabs the zeros and reports `days_left=0`, +`total_days=0`, etc., and exits "successfully". + +So your hypothesis was right in spirit (the data isn't fully loaded when we read) but +slightly off on the *where*: the click and navigation already work — it's the +post-navigation **hydration** of the data panel that we're not waiting for. + +--- + +## Evidence (so you don't have to re-derive it) + +1. **The selectors are correct.** The fully-loaded capture + `captures/05_foraldrapenning_overview.html` contains the real values using the exact + selectors the scraper already uses: + - `visa-dagar__dagar-kvar` → `164`, `236`, `39` + - `av 240 dagar`, `av 480 dagar` + - tier `(119 dagar)`, `(45 dagar)`, `(146 dagar)`, `(90 dagar)` + So this is **not** a selector problem. + +2. **The logs show a silent all-zero read, not an error.** From your run: + ``` + days_left=0 + total_days=0 days_used=0 + niva_texts raw=['(0 dagar)', '(0 dagar)'] + dubbeldagar_left=0 + ``` + Every selector matched and returned text — the text was just the placeholder `0`. + +3. **Nothing catches it.** In `_extract_parental_leave`, the consistency checks all + pass for an all-zero panel: `days_used = 0 - 0 = 0`, and the tier cross-check + `0 + 0 == 0` holds. So garbage is happily persisted. + +**Where it goes wrong in code:** `_do_extract` (around lines 391–394) waits for +`_SEL.STEP_DAGAR_KVAR` to *exist*, then immediately calls `_extract_parental_leave`. +`wait_for_selector` resolves on DOM presence, not on data being populated. + +--- + +## The fix + +Two changes, both small. Item 1 is the actual fix; item 2 is a cheap safety net so we +never silently persist zeros again. + +### 1. Wait for the data to hydrate (primary fix) — `_do_extract` + +After the panel container appears, wait until the **total days** value is positive +before extracting. Total is always `240` (for you) / `480` (for the child) and is +**never legitimately 0**, which makes it a safe, reliable "data has loaded" signal. +(Don't key off `days_left` — that one *can* legitimately be 0 for a parent who has used +everything.) + +In `_do_extract`, after the existing `wait_for_selector(_SEL.STEP_DAGAR_KVAR)`: + +```python +# Wait for the valjdagar data page container to mount +await page.wait_for_selector(_SEL.STEP_DAGAR_KVAR, timeout=30_000) + +# The panel mounts with placeholder zeros, then hydrates from an async fetch. +# Wait until the "av N dagar" total is positive — total is always 240/480, never 0, +# so this is a reliable "real data has arrived" signal (days_left can legitimately be 0). +logger.debug("[%s] waiting for valjdagar data to hydrate", state.session_id) +await page.wait_for_function( + """(sel) => { + const el = document.querySelector(sel); + if (!el) return false; + const m = el.textContent.match(/av\\s+(\\d+)\\s+dagar/); + return m && parseInt(m[1], 10) > 0; + }""", + arg=_SEL.FOR_DIG_DISCRETE, + timeout=30_000, +) +logger.debug("[%s] valjdagar data hydrated (url=%s)", state.session_id, page.url) +``` + +Notes for whoever implements this: +- `page.wait_for_function` runs the JS in the browser and polls until it returns + truthy or times out. The `arg=` value is passed in as the function's parameter. +- `_SEL.FOR_DIG_DISCRETE` is `"[data-test='forDig'] .visa-dagar__discrete"`, whose text + is `"av 240 dagar kvar efter ansökan"`. Same string `_extract_parental_leave` already + parses, so we're waiting on exactly the value we're about to read. +- Keep the existing `wait_for_selector` line — mount first, then hydrate. + +### 2. Guard against placeholder/zero data (safety net) — `_extract_parental_leave` + +Even with the wait, add a loud failure if we somehow still read the placeholder, so we +fail visibly instead of persisting zeros. Put this right after `total_days` is parsed +(around line 430): + +```python +if total_days == 0: + raise ValueError( + "FK panel returned total_days=0 — data had not hydrated when read " + "(placeholder state). The hydration wait in _do_extract should prevent this." + ) +``` + +This complements the existing `days_left < 0` and reserved-days checks. + +### 3. (Optional, senior) Save debug artifacts on this failure path + +`_save_debug_artifacts` already exists and is called from `_run_session`'s `except` +block — so once item 2 raises, you'll automatically get HTML + screenshot + console +dumped to `settings.scraper_debug_dir` (if configured). Just confirm +`APP_SCRAPER_DEBUG_DIR` is set when reproducing, and you'll capture the exact DOM state +if it ever recurs. + +--- + +## Optional follow-up (separate ticket — senior, NOT part of this fix) + +`_do_extract` clicks `_SEL.ANSOK_BUTTON` via `.first`. If a user has **multiple +children**, there are multiple *"Ansök för "* buttons and `.first` picks the wrong +one. We already carry `state.child_id`; a future change should resolve the correct +button by child name rather than blindly taking the first. Out of scope here — flag it, +don't fix it in this PR. + +--- + +## Tests to add (`backend/tests/unit/test_fk_scraper.py`) + +The existing tests use a regex-backed mock page (`_make_mock_page`) and never touch a +real browser — follow that pattern. Add: + +1. **Zero-guard test:** build a mock page whose `FOR_DIG_DISCRETE` returns + `"av 0 dagar kvar efter ansökan"` and assert `_extract_parental_leave` raises + `ValueError` (mirror `test_total_days_parse_failure_raises` at line ~287). + +2. **Hydration-wait test (`_do_extract`):** mock `page.wait_for_selector` and + `page.wait_for_function` as `AsyncMock`s and assert `_do_extract` awaits + `wait_for_function` **before** extracting. A simple version: give the mock page a + `wait_for_function` that flips a fixture's discrete text from `"av 0 dagar"` to + `"av 240 dagar"` on first call, and assert the final result has `total_days == 240`. + (If wiring the ordering is fiddly, asserting `wait_for_function` was awaited once with + `arg=_SEL.FOR_DIG_DISCRETE` is enough to lock in the behavior.) + +Keep the all-zero capture out of the happy-path fixture — `05_foraldrapenning_overview.html` +is the fully-loaded page and should keep passing the existing extractor tests unchanged. + +--- + +## How to verify + +Backend checks (from `backend/`): +```bash +uv run ruff check . +uv run ruff format --check . +uv run pytest +``` +Then a live, non-headless run (from `frontend/`): `npm run login`. Watch for the new +`valjdagar data hydrated` debug line, and confirm the logged figures match what's on +screen (e.g. `days_left=164`, `total_days=240`, dubbeldagar `39`). + +After editing, run `graphify update .` to keep the graph current. diff --git a/backend/app/scrapers/fk_scraper.py b/backend/app/scrapers/fk_scraper.py index 2380943..e2d9ea7 100644 --- a/backend/app/scrapers/fk_scraper.py +++ b/backend/app/scrapers/fk_scraper.py @@ -229,7 +229,9 @@ async def _check_maintenance(page) -> str | None: # Found maintenance notice; extract the message # The message is in a nearby .sv-text-portlet-content div - message_el = page.locator("h2.rubrik:has-text('Tekniskt underhål') ~ div .sv-text-portlet-content") + message_el = page.locator( + "h2.rubrik:has-text('Tekniskt underhål') ~ div .sv-text-portlet-content" + ) if await message_el.count() > 0: message_text = await message_el.first.inner_text(timeout=3_000) return message_text.strip() @@ -261,7 +263,8 @@ async def _save_debug_artifacts(page, session_id: str, console_messages: list[st logger.info( "scraper debug artifacts saved [id=%s] dir=%s", - session_id, out.resolve(), + session_id, + out.resolve(), ) except Exception as artifact_exc: logger.warning("failed to save debug artifacts [id=%s]: %s", session_id, artifact_exc) @@ -373,7 +376,9 @@ async def _do_login(page, state: _SessionState) -> None: return _set_status(state, "authenticating") - logger.debug("[%s] authenticating — waiting for Ansök button (url=%s)", state.session_id, page.url) + logger.debug( + "[%s] authenticating — waiting for Ansök button (url=%s)", state.session_id, page.url + ) # Wait for post-login landing (the Ansök button appears after JS hydrates the page) await page.wait_for_selector(_SEL.ANSOK_BUTTON, timeout=30_000) @@ -393,6 +398,22 @@ async def _do_extract(page, state: _SessionState) -> None: await page.wait_for_selector(_SEL.STEP_DAGAR_KVAR, timeout=30_000) logger.debug("[%s] valjdagar page loaded (url=%s)", state.session_id, page.url) + # The panel mounts with placeholder zeros, then hydrates from an async fetch. + # Wait until the "av N dagar" total is positive — total is always 240/480, never 0, + # so this is a reliable "real data has arrived" signal (days_left can legitimately be 0). + logger.debug("[%s] waiting for valjdagar data to hydrate", state.session_id) + await page.wait_for_function( + """(sel) => { + const el = document.querySelector(sel); + if (!el) return false; + const m = el.textContent.match(/av\\s+(\\d+)\\s+dagar/); + return m && parseInt(m[1], 10) > 0; + }""", + arg=_SEL.FOR_DIG_DISCRETE, + timeout=30_000, + ) + logger.debug("[%s] valjdagar data hydrated (url=%s)", state.session_id, page.url) + data = await _extract_parental_leave(page, state) if state.persist_callback is not None: @@ -428,6 +449,11 @@ async def _extract_parental_leave(page, state: _SessionState) -> ParentalLeaveFe if not total_match: raise ValueError(f"Could not parse total days from: {discrete_text!r}") total_days = int(total_match.group(1)) + if total_days == 0: + raise ValueError( + "FK panel returned total_days=0 — data had not hydrated when read " + "(placeholder state). The hydration wait in _do_extract should prevent this." + ) days_used = total_days - days_left logger.debug("[%s] total_days=%d days_used=%d", state.session_id, total_days, days_used) @@ -452,14 +478,19 @@ async def _extract_parental_leave(page, state: _SessionState) -> ParentalLeaveFe sjukpenning_left, lagsta_niva_left = niva_numbers[0], niva_numbers[1] logger.debug( "[%s] sjukpenning_left=%d lagsta_niva_left=%d", - state.session_id, sjukpenning_left, lagsta_niva_left, + state.session_id, + sjukpenning_left, + lagsta_niva_left, ) # Cross-check: sjukpenning_left + lagsta_niva_left should equal days_left if sjukpenning_left + lagsta_niva_left != days_left: logger.warning( "Tier sum mismatch: sjukpenning=%d + lagstaniva=%d != days_left=%d [id=%s]", - sjukpenning_left, lagsta_niva_left, days_left, state.session_id, + sjukpenning_left, + lagsta_niva_left, + days_left, + state.session_id, ) # -- dubbeldagar remaining -- diff --git a/backend/tests/unit/test_fk_scraper.py b/backend/tests/unit/test_fk_scraper.py index cb1be26..112bee7 100644 --- a/backend/tests/unit/test_fk_scraper.py +++ b/backend/tests/unit/test_fk_scraper.py @@ -15,7 +15,7 @@ from __future__ import annotations import re import uuid -from datetime import date, datetime +from datetime import datetime from pathlib import Path from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -65,7 +65,8 @@ def _make_mock_page(html: str): m = re.search( r'data-test="forDig".*?' r'class="visa-dagar__dagar-kvar">(\d+)<', - html, re.DOTALL, + html, + re.DOTALL, ) if m: return m.group(1) @@ -75,7 +76,8 @@ def _make_mock_page(html: str): m = re.search( r'data-test="forDig".*?' r'class="visa-dagar__discrete">(.*?)<', - html, re.DOTALL, + html, + re.DOTALL, ) if m: return m.group(1) @@ -85,7 +87,8 @@ def _make_mock_page(html: str): m = re.search( r'data-test="dubbeldag".*?' r'class="visa-dagar__dagar-kvar">(\d+)<', - html, re.DOTALL, + html, + re.DOTALL, ) if m: return m.group(1) @@ -94,12 +97,13 @@ def _make_mock_page(html: str): def _niva_all_texts() -> list[str]: m_block = re.search( r'data-test="forDig"(.*?)data-test="footer"', - html, re.DOTALL, + html, + re.DOTALL, ) block = m_block.group(1) if m_block else "" - desktop_m = re.search(r'visa-dagar__desktop(.*?)visa-dagar__mobile', block, re.DOTALL) + desktop_m = re.search(r"visa-dagar__desktop(.*?)visa-dagar__mobile", block, re.DOTALL) target = desktop_m.group(1) if desktop_m else block - return re.findall(r'\(\d+ dagar\)', target) + return re.findall(r"\(\d+ dagar\)", target) page = MagicMock() @@ -107,18 +111,23 @@ def _make_mock_page(html: str): loc = MagicMock() if sel == NIVA_SEL: loc.first.inner_text = AsyncMock(return_value="") + loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=_niva_all_texts()) elif "visa-dagar__dagar-kvar" in sel and "forDig" in sel: loc.first.inner_text = AsyncMock(return_value=_for_dig_kvar()) + loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=[]) elif "visa-dagar__discrete" in sel and "forDig" in sel: loc.first.inner_text = AsyncMock(return_value=_for_dig_discrete()) + loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=[]) elif "dubbeldag" in sel and "visa-dagar__dagar-kvar" in sel: loc.first.inner_text = AsyncMock(return_value=_dubbeldag_kvar()) + loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=[]) else: loc.first.inner_text = AsyncMock(return_value="") + loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=[]) return loc @@ -216,7 +225,10 @@ class TestExtractFromFixture: ) page = _make_mock_page(html) result = await _extract_parental_leave(page, state) - assert result.tier_data.sjukpenning_total + result.tier_data.lagsta_niva_total == result.total_days + assert ( + result.tier_data.sjukpenning_total + result.tier_data.lagsta_niva_total + == result.total_days + ) async def test_tier_used_nonnegative(self) -> None: html = _load_fixture("05_foraldrapenning_overview.html") @@ -240,7 +252,9 @@ class TestExtractFromFixture: class TestValidation: """Feed impossible numbers and assert the extractor raises.""" - def _make_bad_page(self, days_left: int, total_days: int, sjukpenning: int, lagsta: int, dubbel: int) -> Any: + def _make_bad_page( + self, days_left: int, total_days: int, sjukpenning: int, lagsta: int, dubbel: int + ) -> Any: NIVA_SEL = "[data-test='forDig'] .visa-dagar__desktop .niva-statistik__dagar span" page = MagicMock() @@ -249,22 +263,27 @@ class TestValidation: loc = MagicMock() if sel == NIVA_SEL: loc.first.inner_text = AsyncMock(return_value="") + loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock( return_value=[f"({sjukpenning} dagar)", f"({lagsta} dagar)"] ) elif "visa-dagar__dagar-kvar" in sel and "forDig" in sel: loc.first.inner_text = AsyncMock(return_value=str(days_left)) + loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=[]) elif "visa-dagar__discrete" in sel and "forDig" in sel: loc.first.inner_text = AsyncMock( return_value=f"av {total_days} dagar kvar efter ansökan" ) + loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=[]) elif "dubbeldag" in sel: loc.first.inner_text = AsyncMock(return_value=str(dubbel)) + loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=[]) else: loc.first.inner_text = AsyncMock(return_value="") + loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=[]) return loc @@ -292,18 +311,23 @@ class TestValidation: loc = MagicMock() if sel == NIVA_SEL: loc.first.inner_text = AsyncMock(return_value="") + loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=["(80 dagar)", "(20 dagar)"]) elif "dagar-kvar" in sel and "forDig" in sel: loc.first.inner_text = AsyncMock(return_value="100") + loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=[]) elif "discrete" in sel and "forDig" in sel: loc.first.inner_text = AsyncMock(return_value="INVALID TEXT") + loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=[]) elif "dubbeldag" in sel: loc.first.inner_text = AsyncMock(return_value="39") + loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=[]) else: loc.first.inner_text = AsyncMock(return_value="0") + loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=[]) return loc @@ -319,18 +343,23 @@ class TestValidation: loc = MagicMock() if sel == NIVA_SEL: loc.first.inner_text = AsyncMock(return_value="") + loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=["(119 dagar)"]) # only one elif "dagar-kvar" in sel and "forDig" in sel: loc.first.inner_text = AsyncMock(return_value="164") + loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=[]) elif "discrete" in sel and "forDig" in sel: loc.first.inner_text = AsyncMock(return_value="av 240 dagar kvar efter ansökan") + loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=[]) elif "dubbeldag" in sel: loc.first.inner_text = AsyncMock(return_value="39") + loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=[]) else: loc.first.inner_text = AsyncMock(return_value="0") + loc.first.wait_for = AsyncMock() loc.all_inner_texts = AsyncMock(return_value=[]) return loc @@ -338,6 +367,12 @@ class TestValidation: with pytest.raises(ValueError, match="Expected 2 tier values"): await _extract_parental_leave(page, self._state()) + async def test_zero_total_days_raises(self) -> None: + """Guard against placeholder zero data: total_days=0 is impossible and indicates hydration failure.""" + page = self._make_bad_page(164, 0, 119, 45, 39) + with pytest.raises(ValueError, match="total_days=0.*had not hydrated"): + await _extract_parental_leave(page, self._state()) + # --------------------------------------------------------------------------- # Session state machine @@ -388,6 +423,7 @@ class TestSessionStateMachine: assert result["status"] == "awaiting_scan" assert "qr_data" in result import base64 + assert base64.b64decode(result["qr_data"]) == b"PNG_BYTES" async def test_cancel_sets_status(self) -> None: @@ -487,6 +523,7 @@ class TestSessionStateMachine: page = MagicMock() page.wait_for_selector = AsyncMock() + page.wait_for_function = AsyncMock() def locator_factory(sel: str) -> MagicMock: loc = MagicMock() @@ -544,6 +581,7 @@ class TestSessionStateMachine: page = MagicMock() page.wait_for_selector = AsyncMock() + page.wait_for_function = AsyncMock() def locator_factory(sel: str) -> MagicMock: loc = MagicMock() @@ -561,6 +599,60 @@ class TestSessionStateMachine: assert state.status == "error" assert "persist" in (state.message or "").lower() + async def test_do_extract_waits_for_hydration_before_extracting(self) -> None: + """_do_extract calls wait_for_function to ensure data hydrates before extraction.""" + now = datetime.now() + expected = ParentalLeaveFetch( + id=uuid.uuid4(), + personal_data_uuid=uuid.uuid4(), + workspace_id=uuid.uuid4(), + child_id=uuid.uuid4(), + source="scraped", + fetched_at=now, + total_days=240, + days_used=0, + days_left=240, + reserved_days_total=90, + reserved_days_left=90, + transferable_days_left=150, + dubbeldagar_left=60, + tier_data=TierData(195, 0, 195, 45, 0, 45), + created_at=now, + updated_at=now, + ) + + sid = str(uuid.uuid4()) + state = _SessionState( + session_id=sid, + workspace_id=uuid.uuid4(), + personal_data_uuid=uuid.uuid4(), + child_id=uuid.uuid4(), + ) + _sessions[sid] = state + + page = MagicMock() + page.wait_for_selector = AsyncMock() + page.wait_for_function = AsyncMock() + + def locator_factory(sel: str) -> MagicMock: + loc = MagicMock() + loc.first.click = AsyncMock() + return loc + + page.locator.side_effect = locator_factory + + with patch( + "app.scrapers.fk_scraper._extract_parental_leave", new_callable=AsyncMock + ) as mock_extract: + mock_extract.return_value = expected + await _do_extract(page, state) + + # Verify wait_for_function was called with the correct selector + page.wait_for_function.assert_called_once() + call_args = page.wait_for_function.call_args + assert call_args[1]["arg"] == "[data-test='forDig'] .visa-dagar__discrete" + assert call_args[1]["timeout"] == 30_000 + # --------------------------------------------------------------------------- # Persistence integration: real SQLite repo round-trip @@ -580,9 +672,6 @@ class TestPersistenceIntegration: self, parental_leave_repo, seeded_ids ) -> None: """After _do_extract completes, repo.get_latest_by_parent returns the scraped row.""" - from app.repositories_impl.sqlite.parental_leave_repository import ( - SQLiteParentalLeaveRepository, - ) async def persist_callback(fetch: ParentalLeaveFetch) -> None: await parental_leave_repo.create(fetch) @@ -619,6 +708,7 @@ class TestPersistenceIntegration: page = MagicMock() page.wait_for_selector = AsyncMock() + page.wait_for_function = AsyncMock() def locator_factory(sel: str) -> MagicMock: loc = MagicMock() diff --git a/backend/tests/unit/test_planning_service.py b/backend/tests/unit/test_planning_service.py index 757b2d7..0ef9cf5 100644 --- a/backend/tests/unit/test_planning_service.py +++ b/backend/tests/unit/test_planning_service.py @@ -154,14 +154,14 @@ class TestPlanningService: ws = seeded_ids.workspace_id child_id = seeded_ids.child_id - d1 = await planning_service.add_planned_day( + await planning_service.add_planned_day( workspace_id=ws, child_id=child_id, parent_personal_data_uuid=seeded_ids.personal_data_uuid, date=date(2026, 6, 15), day_type="vacation", ) - d2 = await planning_service.add_planned_day( + await planning_service.add_planned_day( workspace_id=ws, child_id=child_id, parent_personal_data_uuid=seeded_ids.personal_data_uuid, diff --git a/frontend/src/components/common/LoadingSpinner.tsx b/frontend/src/components/common/LoadingSpinner.tsx index 6071b42..9229bd4 100644 --- a/frontend/src/components/common/LoadingSpinner.tsx +++ b/frontend/src/components/common/LoadingSpinner.tsx @@ -1,4 +1,5 @@ import { ReactNode } from 'react' +import './loading-spinner.css' interface LoadingSpinnerProps { message?: string @@ -7,9 +8,9 @@ interface LoadingSpinnerProps { export function LoadingSpinner({ message = 'Loading...', ariaLabel = 'Loading' }: LoadingSpinnerProps): ReactNode { return ( -
-
⊙
-

{message}

+
+
⊙
+

{message}

) } diff --git a/frontend/src/components/common/loading-spinner.css b/frontend/src/components/common/loading-spinner.css new file mode 100644 index 0000000..33f4d95 --- /dev/null +++ b/frontend/src/components/common/loading-spinner.css @@ -0,0 +1,39 @@ +/* Loading Spinner */ +.loading-spinner { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--sp-8); + gap: var(--sp-4); + min-height: 300px; +} + +.loading-spinner__icon { + font-size: 48px; + color: var(--c-accent); + animation: spin 2s linear infinite; + display: block; +} + +.loading-spinner__message { + font-size: var(--fs-base); + color: var(--c-text-muted); + margin: 0; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* Reduce Motion */ +@media (prefers-reduced-motion: reduce) { + .loading-spinner__icon { + animation: none; + } +} diff --git a/frontend/src/components/dashboard/OverviewMetrics.tsx b/frontend/src/components/dashboard/OverviewMetrics.tsx index c870396..8ff63e4 100644 --- a/frontend/src/components/dashboard/OverviewMetrics.tsx +++ b/frontend/src/components/dashboard/OverviewMetrics.tsx @@ -13,18 +13,19 @@ interface OverviewMetricsProps { export function OverviewMetrics({ metrics, title = 'Overview' }: OverviewMetricsProps): ReactNode { return ( -
-

{title}

-
+
+

{title}

+
{metrics.map((metric, index) => (
- {metric.label}: - {metric.value} + {metric.label}: + {metric.value}
))}
diff --git a/frontend/src/components/layout/Footer.tsx b/frontend/src/components/layout/Footer.tsx index bcb4c5d..ec98764 100644 --- a/frontend/src/components/layout/Footer.tsx +++ b/frontend/src/components/layout/Footer.tsx @@ -1,4 +1,5 @@ import { ReactNode } from 'react' +import './footer.css' export function Footer(): ReactNode { const currentYear = new Date().getFullYear() diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index 4a264a7..2ac357b 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -4,6 +4,7 @@ import { useAuth } from '@/context/AuthContext' import { useWorkspace } from '@/context/WorkspaceContext' import { Header, HeaderProps } from './Header' import { Footer } from './Footer' +import './layout.css' interface LayoutProps { children: ReactNode diff --git a/frontend/src/components/layout/footer.css b/frontend/src/components/layout/footer.css new file mode 100644 index 0000000..e917d4a --- /dev/null +++ b/frontend/src/components/layout/footer.css @@ -0,0 +1,13 @@ +footer { + margin-top: var(--sp-8); + padding: var(--sp-5); + border-top: 1px solid var(--c-border); + background-color: var(--c-bg); + text-align: center; + color: var(--c-text-muted); +} + +footer p { + margin: 0; + font-size: var(--fs-sm); +} diff --git a/frontend/src/components/layout/header.css b/frontend/src/components/layout/header.css index 94386f4..2b47261 100644 --- a/frontend/src/components/layout/header.css +++ b/frontend/src/components/layout/header.css @@ -1,83 +1,113 @@ .header { - background-color: #f5f5f5; - border-bottom: 1px solid #e0e0e0; - padding: 16px 20px; + position: sticky; + top: 0; + z-index: 100; + background-color: var(--c-surface); + border-bottom: 1px solid var(--c-border); + padding: var(--sp-4) var(--sp-5); + box-shadow: var(--shadow-sm); } .header__top { + max-width: var(--maxw); + margin: 0 auto var(--sp-4); display: flex; justify-content: space-between; align-items: center; - margin-bottom: 16px; } .header__title { - font-size: 18px; - font-weight: 600; - color: #333; + font-size: var(--fs-lg); + font-weight: 700; + color: var(--c-text); } .header__identity { display: flex; align-items: center; - gap: 12px; + gap: var(--sp-4); } .header__parent-name { - font-size: 14px; - color: #666; + font-size: var(--fs-sm); + color: var(--c-text-muted); font-weight: 500; } .header__child-select { - padding: 6px 10px; - border: 1px solid #d0d0d0; - border-radius: 4px; - font-size: 14px; - background-color: #fff; + padding: var(--sp-2) var(--sp-3); + border: 1px solid var(--c-border); + border-radius: var(--radius); + font-size: var(--fs-sm); + background-color: var(--c-surface); + color: var(--c-text); cursor: pointer; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.header__child-select:focus { + outline: none; + border-color: var(--c-accent); + box-shadow: 0 0 0 3px rgba(47, 111, 79, 0.1); } .header__child-name { - font-size: 14px; - color: #0066cc; + font-size: var(--fs-sm); + color: var(--c-accent); font-weight: 500; } .header__nav { + max-width: var(--maxw); + margin: 0 auto; display: flex; - gap: 8px; + gap: var(--sp-3); flex-wrap: wrap; + justify-content: center; } .header__nav-link { - padding: 8px 14px; - font-size: 14px; + padding: var(--sp-2) var(--sp-4); + font-size: var(--fs-sm); text-decoration: none; - color: #333; - border-radius: 4px; - transition: background-color 0.2s; + color: var(--c-text); + border-radius: var(--radius); + border-bottom: 2px solid transparent; + transition: background-color 0.2s, border-color 0.2s; } .header__nav-link:hover { - background-color: #e8e8e8; + background-color: var(--c-bg); +} + +.header__nav-link:focus-visible { + outline: 2px solid var(--c-accent); + outline-offset: 2px; } .header__nav-link--active { - background-color: #0066cc; - color: #fff; + background-color: var(--c-accent); + color: var(--c-surface); + border-bottom-color: var(--c-accent); } .header__logout-button { - padding: 8px 14px; - font-size: 14px; - background-color: #f8f8f8; - border: 1px solid #d0d0d0; - border-radius: 4px; + padding: var(--sp-2) var(--sp-4); + font-size: var(--fs-sm); + background-color: transparent; + border: 1px solid var(--c-border); + border-radius: var(--radius); + color: var(--c-text); cursor: pointer; - transition: background-color 0.2s; + transition: background-color 0.2s, border-color 0.2s; } .header__logout-button:hover { - background-color: #e8e8e8; + background-color: var(--c-bg); + border-color: var(--c-accent); +} + +.header__logout-button:focus-visible { + outline: 2px solid var(--c-accent); + outline-offset: 2px; } diff --git a/frontend/src/components/layout/layout.css b/frontend/src/components/layout/layout.css new file mode 100644 index 0000000..9ae98e6 --- /dev/null +++ b/frontend/src/components/layout/layout.css @@ -0,0 +1,112 @@ +/* Layout container */ +[data-testid='layout'] { + display: flex; + flex-direction: column; + min-height: 100vh; + background-color: var(--c-bg); +} + +[data-testid='layout-main'] { + flex: 1; + max-width: var(--maxw); + width: 100%; + margin: 0 auto; + padding: var(--sp-5); +} + +/* Dashboard & Pages */ +.dashboard, +.calendar, +.settings-page, +.tips-page, +.auth-page { + width: 100%; +} + +.dashboard__section, +.settings-page__section, +.calendar__section, +.tips-page__section { + background-color: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius); + padding: var(--sp-5); + margin-bottom: var(--sp-5); + box-shadow: var(--shadow-sm); +} + +.dashboard__section:last-child, +.settings-page__section:last-child, +.calendar__section:last-child, +.tips-page__section:last-child { + margin-bottom: 0; +} + +.dashboard__section h2, +.settings-page__section h2, +.calendar__section h2, +.tips-page__section h2 { + margin-top: 0; +} + +.dashboard__error { + padding: var(--sp-4); + background-color: var(--c-danger-weak); + color: var(--c-danger); + border-left: 4px solid var(--c-danger); + border-radius: var(--radius); + margin-bottom: var(--sp-5); +} + +.dashboard__break-list { + list-style: none; + margin: 0; + padding: 0; +} + +.dashboard__break-item { + padding: var(--sp-3) var(--sp-4); + border-bottom: 1px solid var(--c-border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.dashboard__break-item:last-child { + border-bottom: none; +} + +.dashboard__break-item strong { + display: block; + margin-bottom: var(--sp-2); +} + +.dashboard__break-item small { + color: var(--c-text-muted); + font-size: var(--fs-sm); +} + +/* Status & Freshness Info */ +.status-info, +.freshness-info { + font-size: var(--fs-sm); + color: var(--c-text-muted); + margin-top: var(--sp-3); + padding-top: var(--sp-3); + border-top: 1px solid var(--c-border); +} + +/* Responsive */ +@media (max-width: 640px) { + [data-testid='layout-main'] { + padding: var(--sp-4); + } + + .dashboard__section, + .settings-page__section, + .calendar__section, + .tips-page__section { + padding: var(--sp-4); + margin-bottom: var(--sp-4); + } +} diff --git a/frontend/src/components/modals/QRCodeModal.tsx b/frontend/src/components/modals/QRCodeModal.tsx index 7a5f301..5e6b0ad 100644 --- a/frontend/src/components/modals/QRCodeModal.tsx +++ b/frontend/src/components/modals/QRCodeModal.tsx @@ -1,5 +1,6 @@ import { ReactNode } from 'react' import type { ScraperStatus } from '@/hooks/useScraperFetch' +import './qrcode-modal.css' interface QRCodeModalProps { isOpen: boolean @@ -34,102 +35,77 @@ export function QRCodeModal({ } return ( -
-
-
-

Hämta från Försäkringskassan

+
+
+
+

Hämta från Försäkringskassan

{status && ( -
-
- Status:{' '} - +
+
+ Status: + {status} - {message && — {message}} + {message && — {message}}
{status === 'awaiting_scan' && qrData && ( -
-

Öppna BankID-appen och skanna QR-koden:

+
+

Öppna BankID-appen och skanna QR-koden:

BankID QR-kod
)} {(status === 'authenticating' || status === 'fetching') && ( -

+

⏳ {status === 'authenticating' ? 'Autentiserar…' : 'Hämtar data från FK…'}

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

✅ Klar! Data hämtad från Försäkringskassan.

-
+

✅ Klar! Data hämtad från Försäkringskassan.

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

Sessionen avbröts.

+

Sessionen avbröts.

)} - {status === 'done' && ( - - )} +
+ {status === 'done' && ( + + )} - {isRunning && ( - - )} + {isRunning && ( + + )} +
)} {error && ( -
+
Fel: {error}
)} diff --git a/frontend/src/components/modals/qrcode-modal.css b/frontend/src/components/modals/qrcode-modal.css new file mode 100644 index 0000000..d8333ec --- /dev/null +++ b/frontend/src/components/modals/qrcode-modal.css @@ -0,0 +1,226 @@ +/* QRCode Modal Container */ +.qrcode-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(16, 24, 40, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.qrcode-modal { + background-color: var(--c-surface); + border-radius: var(--radius); + box-shadow: var(--shadow-md); + max-width: 500px; + width: 90%; + padding: var(--sp-6); + max-height: 90vh; + overflow-y: auto; +} + +/* Modal Header */ +.qrcode-modal__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--sp-5); +} + +.qrcode-modal__title { + font-size: var(--fs-xl); + font-weight: 700; + margin: 0; +} + +.qrcode-modal__close-btn { + background: none; + border: none; + font-size: var(--fs-lg); + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius); + transition: background-color 0.2s; + color: var(--c-text); +} + +.qrcode-modal__close-btn:hover:not(:disabled) { + background-color: var(--c-bg); +} + +.qrcode-modal__close-btn:focus-visible { + outline: 2px solid var(--c-accent); + outline-offset: 2px; +} + +.qrcode-modal__close-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Status Display */ +.qrcode-modal__status-container { + margin-bottom: var(--sp-5); +} + +.qrcode-modal__status-row { + margin-bottom: var(--sp-4); +} + +.qrcode-modal__status-label { + font-weight: 600; + margin-right: var(--sp-2); +} + +.qrcode-modal__status-value { + font-weight: 600; +} + +.qrcode-modal__status-value--pending { + color: var(--c-warning); +} + +.qrcode-modal__status-value--success { + color: var(--c-success); +} + +.qrcode-modal__status-value--error { + color: var(--c-danger); +} + +.qrcode-modal__status-message { + color: var(--c-text-muted); + margin-left: var(--sp-2); + font-size: var(--fs-sm); +} + +/* QR Code Display */ +.qrcode-modal__qr-section { + margin-bottom: var(--sp-5); + text-align: center; +} + +.qrcode-modal__qr-instruction { + font-size: var(--fs-sm); + color: var(--c-text-muted); + margin-bottom: var(--sp-4); +} + +.qrcode-modal__qr-image { + max-width: 300px; + width: 100%; + height: auto; + border: 1px solid var(--c-border); + border-radius: var(--radius); + background-color: var(--c-surface); + padding: var(--sp-2); + image-rendering: pixelated; +} + +/* Status Messages */ +.qrcode-modal__loading-message { + color: var(--c-text-muted); + text-align: center; + padding: var(--sp-4); +} + +.qrcode-modal__success-message { + color: var(--c-success); + text-align: center; + padding: var(--sp-4); +} + +.qrcode-modal__cancelled-message { + color: var(--c-text-muted); + text-align: center; + padding: var(--sp-4); +} + +.qrcode-modal__error { + padding: var(--sp-3) var(--sp-4); + background-color: var(--c-danger-weak); + border-left: 4px solid var(--c-danger); + border-radius: var(--radius); + color: var(--c-danger); + margin-bottom: var(--sp-4); + font-size: var(--fs-sm); +} + +.qrcode-modal__error strong { + display: block; + margin-bottom: var(--sp-2); +} + +/* Button Group */ +.qrcode-modal__button-group { + display: flex; + gap: var(--sp-3); + margin-top: var(--sp-5); +} + +.qrcode-modal__button { + flex: 1; + padding: var(--sp-3) var(--sp-4); + border: none; + border-radius: var(--radius); + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; +} + +.qrcode-modal__button--success { + background-color: var(--c-success); + color: var(--c-surface); +} + +.qrcode-modal__button--success:hover { + background-color: #0d6a37; +} + +.qrcode-modal__button--danger { + background-color: var(--c-danger); + color: var(--c-surface); +} + +.qrcode-modal__button--danger:hover { + background-color: #8a1a15; +} + +.qrcode-modal__button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.qrcode-modal__button:focus-visible { + outline: 2px solid var(--c-accent); + outline-offset: 2px; +} + +/* Responsive */ +@media (max-width: 640px) { + .qrcode-modal { + width: 95%; + padding: var(--sp-4); + } + + .qrcode-modal__qr-image { + max-width: 250px; + } + + .qrcode-modal__button-group { + flex-direction: column-reverse; + } + + .qrcode-modal__button { + width: 100%; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index be31229..e945c31 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { App } from './App' +import './styles/global.css' const root = document.getElementById('root') if (!root) throw new Error('Root element #root not found') diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css new file mode 100644 index 0000000..b0cf674 --- /dev/null +++ b/frontend/src/styles/components.css @@ -0,0 +1,391 @@ +/* Overview Metrics */ +.overview-metrics { + display: grid; + gap: var(--sp-4); +} + +.overview-metrics__title { + margin: 0 0 var(--sp-4) 0; +} + +.overview-metrics__list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: var(--sp-4); +} + +.overview-metrics__item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--sp-5); + background-color: var(--c-bg); + border-radius: var(--radius); + border: 1px solid var(--c-border); +} + +.overview-metrics__value { + display: block; + font-size: var(--fs-2xl); + font-weight: 700; + color: var(--c-accent); + margin-bottom: var(--sp-2); + line-height: 1; +} + +.overview-metrics__label { + font-size: var(--fs-sm); + color: var(--c-text-muted); + text-align: center; +} + +/* Days Breakdown */ +.days-breakdown { + width: 100%; +} + +.days-breakdown__empty { + color: var(--c-text-muted); + font-size: var(--fs-sm); + text-align: center; + padding: var(--sp-5); + margin: 0; +} + +.days-breakdown__table { + width: 100%; + border-collapse: collapse; + margin: 0; +} + +.days-breakdown__table th { + padding: 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); +} + +.days-breakdown__table td { + padding: var(--sp-3) var(--sp-4); + border-bottom: 1px solid var(--c-border); + font-size: var(--fs-base); +} + +.days-breakdown__table tbody tr:hover { + background-color: rgba(47, 111, 79, 0.02); +} + +.days-breakdown__table tbody tr:last-child td { + border-bottom: none; +} + +.days-breakdown__table td:last-child { + text-align: right; + font-weight: 600; + color: var(--c-accent); +} + +/* Parental Leave Breakdown */ +.parental-leave-breakdown { + display: flex; + flex-direction: column; + gap: var(--sp-4); +} + +.parental-leave-breakdown__empty { + color: var(--c-text-muted); + font-size: var(--fs-sm); + padding: var(--sp-4); + margin: 0; +} + +.parental-leave-breakdown__section { + width: 100%; + overflow-x: auto; +} + +.parental-leave-breakdown__table { + width: 100%; + border-collapse: collapse; + margin: 0; +} + +.parental-leave-breakdown__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); +} + +.parental-leave-breakdown__table td { + padding: var(--sp-3) var(--sp-4); + border-bottom: 1px solid var(--c-border); + font-size: var(--fs-sm); +} + +.parental-leave-breakdown__table tbody tr:hover { + background-color: rgba(47, 111, 79, 0.02); +} + +.parental-leave-breakdown__table tbody tr:last-child td { + border-bottom: none; +} + +.parental-leave-breakdown__table td:nth-child(2), +.parental-leave-breakdown__table td:nth-child(3) { + text-align: right; + font-weight: 600; +} + +.parental-leave-breakdown__provenance { + padding-top: var(--sp-3); + border-top: 1px solid var(--c-border); + font-size: var(--fs-sm); + color: var(--c-text-muted); +} + +/* Progress Chart */ +.progress-chart { + display: flex; + flex-direction: column; + gap: var(--sp-4); +} + +.progress-chart__header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin: 0 0 var(--sp-3) 0; +} + +.progress-chart__header h3 { + margin: 0; +} + +.progress-chart__age { + font-size: var(--fs-sm); + color: var(--c-text-muted); +} + +.progress-chart__bar { + width: 100%; + height: 24px; + background-color: var(--c-bg); + border-radius: var(--radius); + overflow: hidden; + border: 1px solid var(--c-border); +} + +.progress-chart__fill { + height: 100%; + background-color: var(--c-accent); + transition: width 0.3s ease; +} + +.progress-chart__stats { + display: flex; + justify-content: space-between; + align-items: center; + font-size: var(--fs-sm); + color: var(--c-text-muted); +} + +.progress-chart__percentage { + font-weight: 600; + color: var(--c-accent); +} + +/* Button Styles */ +button { + background-color: var(--c-accent); + color: var(--c-surface); + font-weight: 500; +} + +button:hover:not(:disabled) { + background-color: #1f5a3b; +} + +button:active:not(:disabled) { + background-color: #0f4a2f; +} + +/* Secondary button */ +.button--secondary, +button.secondary { + background-color: transparent; + color: var(--c-accent); + border: 1px solid var(--c-accent); +} + +.button--secondary:hover:not(:disabled), +button.secondary:hover:not(:disabled) { + background-color: var(--c-accent-weak); +} + +/* Ghost button */ +.button--ghost, +button.ghost { + background-color: transparent; + color: var(--c-text); + border: 1px solid var(--c-border); +} + +.button--ghost:hover:not(:disabled), +button.ghost:hover:not(:disabled) { + background-color: var(--c-bg); + border-color: var(--c-text); +} + +/* Form styles */ +.form-group { + margin-bottom: var(--sp-5); +} + +.form-group label { + display: block; + margin-bottom: var(--sp-2); + font-weight: 500; + color: var(--c-text); + font-size: var(--fs-base); +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + max-width: 400px; +} + +.form-group textarea { + resize: vertical; + 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 Messages */ +.settings-page__message { + padding: var(--sp-4); + border-radius: var(--radius); + margin-bottom: var(--sp-4); + font-weight: 500; +} + +.settings-page__message--success { + background-color: var(--c-success-weak); + color: var(--c-success); + border-left: 4px solid var(--c-success); +} + +.settings-page__message--error { + background-color: var(--c-danger-weak); + color: var(--c-danger); + border-left: 4px solid var(--c-danger); +} + +/* Fieldsets */ +fieldset { + border: 1px solid var(--c-border); + border-radius: var(--radius); + padding: var(--sp-4); + margin-bottom: var(--sp-5); +} + +legend { + font-weight: 600; + color: var(--c-text); + padding: 0 var(--sp-2); + margin-left: calc(var(--sp-2) * -1); +} + +/* Input Components */ +.vacation-days-input, +.planning-goal-selector, +.break-periods-input, +.parent-profile-input, +.child-birthday-input { + margin-bottom: var(--sp-5); +} + +.vacation-days-input__label, +.planning-goal-selector__label, +.break-periods-input__label, +.parent-profile-input__label, +.child-birthday-input__label { + display: block; + margin-bottom: var(--sp-2); + font-weight: 500; + color: var(--c-text); + font-size: var(--fs-base); +} + +.vacation-days-input__field, +.planning-goal-selector__select, +.break-periods-input__field, +.parent-profile-input__field, +.child-birthday-input__field { + width: 100%; + max-width: 400px; +} + +.vacation-days-input__range, +.break-periods-input__help, +.parent-profile-input__help, +.child-birthday-input__help { + font-size: var(--fs-sm); + color: var(--c-text-muted); + margin-top: var(--sp-2); +} + +/* Responsive */ +@media (max-width: 640px) { + [data-testid='metrics-list'] { + grid-template-columns: 1fr; + } + + .form-group input, + .form-group select, + .form-group textarea, + .vacation-days-input__field, + .planning-goal-selector__select, + .break-periods-input__field, + .parent-profile-input__field, + .child-birthday-input__field { + max-width: none; + } + + .form-grid { + grid-template-columns: 1fr; + } + + .days-breakdown__table { + font-size: var(--fs-sm); + } + + .days-breakdown__table th, + .days-breakdown__table td { + padding: var(--sp-2) var(--sp-3); + } +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css new file mode 100644 index 0000000..305e58b --- /dev/null +++ b/frontend/src/styles/global.css @@ -0,0 +1,343 @@ +/* Component styles - must come first */ +@import './components.css'; +@import './modals.css'; +@import './responsive.css'; + +/* Design Tokens */ +:root { + /* Color - calm, trustworthy palette */ + --c-bg: #f7f8fa; + --c-surface: #ffffff; + --c-border: #e3e6eb; + --c-text: #1c2733; + --c-text-muted: #5b6675; + --c-accent: #2f6f4f; /* calm green */ + --c-accent-weak: #e8f1ec; + --c-danger: #b3261e; + --c-danger-weak: #fdeceb; + --c-success: #1e7d4f; + --c-success-weak: #e7f4ec; + --c-warning: #8a6d1f; + + /* Spacing scale (8px base) */ + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 24px; + --sp-6: 32px; + --sp-8: 48px; + + /* Type scale */ + --fs-sm: 0.875rem; + --fs-base: 1rem; + --fs-lg: 1.25rem; + --fs-xl: 1.5rem; + --fs-2xl: 2rem; + --lh: 1.5; + + /* Shape & depth */ + --radius: 8px; + --shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.06); + --shadow-md: 0 4px 12px rgba(16, 24, 40, 0.08); + + /* Content width */ + --maxw: 960px; + + color-scheme: light; +} + +/* Reset & Base */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body, +div, +span, +applet, +object, +iframe, +h1, +h2, +h3, +h4, +h5, +h6, +p, +blockquote, +pre, +a, +abbr, +acronym, +address, +big, +cite, +code, +del, +dfn, +em, +img, +ins, +kbd, +q, +s, +samp, +small, +strike, +strong, +sub, +sup, +tt, +var, +b, +u, +i, +center, +dl, +dt, +dd, +ol, +ul, +li, +fieldset, +form, +label, +legend, +table, +caption, +tbody, +tfoot, +thead, +tr, +th, +td, +article, +aside, +canvas, +details, +embed, +figure, +figcaption, +footer, +header, +hgroup, +main, +mark, +meter, +nav, +output, +progress, +section, +summary, +time, +video { + margin: 0; + padding: 0; + border: 0; +} + +body { + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + background: var(--c-bg); + color: var(--c-text); + line-height: var(--lh); + font-size: var(--fs-base); +} + +/* Headings */ +h1 { + font-size: var(--fs-2xl); + font-weight: 700; + margin-bottom: var(--sp-4); +} + +h2 { + font-size: var(--fs-xl); + font-weight: 700; + margin-bottom: var(--sp-3); +} + +h3 { + font-size: var(--fs-lg); + font-weight: 600; + margin-bottom: var(--sp-3); +} + +/* Text */ +p { + margin-bottom: var(--sp-3); +} + +small { + font-size: var(--fs-sm); + color: var(--c-text-muted); +} + +/* Lists */ +ul, +ol { + margin-left: var(--sp-5); + margin-bottom: var(--sp-3); +} + +li { + margin-bottom: var(--sp-2); +} + +/* Links */ +a { + color: var(--c-accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +a:focus-visible { + outline: 2px solid var(--c-accent); + outline-offset: 2px; + border-radius: 2px; +} + +/* Buttons - base */ +button { + font-family: inherit; + font-size: var(--fs-base); + cursor: pointer; + border: none; + border-radius: var(--radius); + padding: var(--sp-3) var(--sp-4); + transition: background-color 0.2s, color 0.2s, box-shadow 0.2s; +} + +button:focus-visible { + outline: 2px solid var(--c-accent); + outline-offset: 2px; +} + +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Inputs & Selects */ +input, +select, +textarea { + font-family: inherit; + font-size: var(--fs-base); + border: 1px solid var(--c-border); + border-radius: var(--radius); + padding: var(--sp-3) var(--sp-4); + background: var(--c-surface); + color: var(--c-text); + transition: border-color 0.2s, box-shadow 0.2s; +} + +input:focus, +select:focus, +textarea:focus { + outline: none; + border-color: var(--c-accent); + box-shadow: 0 0 0 3px rgba(47, 111, 79, 0.1); +} + +input:disabled, +select:disabled, +textarea:disabled { + background-color: #f0f0f0; + cursor: not-allowed; +} + +/* Label */ +label { + display: block; + margin-bottom: var(--sp-2); + font-weight: 500; + color: var(--c-text); +} + +/* Tables */ +table { + width: 100%; + border-collapse: collapse; +} + +th { + text-align: left; + font-weight: 600; + padding: var(--sp-3) var(--sp-4); + background-color: var(--c-bg); + border-bottom: 1px solid var(--c-border); + color: var(--c-text-muted); +} + +td { + padding: var(--sp-3) var(--sp-4); + border-bottom: 1px solid var(--c-border); +} + +tr:hover td { + background-color: rgba(47, 111, 79, 0.02); +} + +/* Section container */ +main { + max-width: var(--maxw); + margin: 0 auto; + padding: var(--sp-5); +} + +/* Utility classes for form */ +.form-group { + margin-bottom: var(--sp-5); +} + +.form-group label { + margin-bottom: var(--sp-2); +} + +/* Message boxes */ +.error-message { + padding: var(--sp-3) var(--sp-4); + background-color: var(--c-danger-weak); + color: var(--c-danger); + border-left: 4px solid var(--c-danger); + border-radius: var(--radius); + margin-bottom: var(--sp-4); +} + +.success-message { + padding: var(--sp-3) var(--sp-4); + background-color: var(--c-success-weak); + color: var(--c-success); + border-left: 4px solid var(--c-success); + border-radius: var(--radius); + margin-bottom: var(--sp-4); +} + +.warning-message { + padding: var(--sp-3) var(--sp-4); + background-color: #fef8e7; + color: var(--c-warning); + border-left: 4px solid var(--c-warning); + border-radius: var(--radius); + margin-bottom: var(--sp-4); +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/frontend/src/styles/modals.css b/frontend/src/styles/modals.css new file mode 100644 index 0000000..e5cd477 --- /dev/null +++ b/frontend/src/styles/modals.css @@ -0,0 +1,206 @@ +/* Modal Overlay */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(16, 24, 40, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +/* Modal Content */ +.modal-content { + background-color: var(--c-surface); + border-radius: var(--radius); + box-shadow: var(--shadow-md); + max-width: 500px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + padding: var(--sp-6); +} + +.modal-content h2 { + margin-top: 0; +} + +.modal-content h3 { + margin-top: var(--sp-5); +} + +/* Modal Header */ +.modal__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--sp-5); +} + +.modal__title { + font-size: var(--fs-xl); + font-weight: 700; + margin: 0; +} + +.modal__close { + background: none; + border: none; + font-size: var(--fs-lg); + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius); + transition: background-color 0.2s; +} + +.modal__close:hover { + background-color: var(--c-bg); +} + +.modal__close:focus-visible { + outline: 2px solid var(--c-accent); + outline-offset: 2px; +} + +/* Modal Body */ +.modal__body { + margin-bottom: var(--sp-5); +} + +/* Modal Footer */ +.modal__footer { + display: flex; + gap: var(--sp-3); + justify-content: flex-end; + padding-top: var(--sp-4); + border-top: 1px solid var(--c-border); +} + +.modal__footer button { + min-width: 100px; +} + +/* QRCode Modal */ +.qrcode-modal__content { + text-align: center; +} + +.qrcode-modal__qr { + margin: var(--sp-5) 0; + display: flex; + justify-content: center; +} + +.qrcode-modal__qr img { + max-width: 300px; + width: 100%; + height: auto; + border: 1px solid var(--c-border); + border-radius: var(--radius); + background-color: var(--c-bg); + padding: var(--sp-2); +} + +.qrcode-modal__status { + padding: var(--sp-4); + background-color: var(--c-bg); + border-radius: var(--radius); + margin-bottom: var(--sp-4); + font-size: var(--fs-sm); +} + +.qrcode-modal__status--pending { + background-color: #fef8e7; + color: var(--c-warning); +} + +.qrcode-modal__status--success { + background-color: var(--c-success-weak); + color: var(--c-success); +} + +.qrcode-modal__status--error { + background-color: var(--c-danger-weak); + color: var(--c-danger); +} + +.qrcode-modal__error { + padding: var(--sp-3) var(--sp-4); + background-color: var(--c-danger-weak); + color: var(--c-danger); + border-left: 4px solid var(--c-danger); + border-radius: var(--radius); + margin-bottom: var(--sp-4); + font-size: var(--fs-sm); +} + +/* Loading Spinner */ +[data-testid='loading-spinner'] { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--sp-8); + gap: var(--sp-4); +} + +[data-testid='spinner-icon'] { + font-size: 48px; + color: var(--c-accent); + animation: spin 2s linear infinite; +} + +[data-testid='spinner-message'] { + font-size: var(--fs-base); + color: var(--c-text-muted); + margin: 0; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* Reduce Motion */ +@media (prefers-reduced-motion: reduce) { + [data-testid='spinner-icon'] { + animation: none; + } + + .modal-content { + animation: none; + } +} + +/* Responsive */ +@media (max-width: 640px) { + .modal-content { + width: 95%; + padding: var(--sp-4); + max-height: 95vh; + } + + .qrcode-modal__qr img { + max-width: 250px; + } + + .modal__footer { + flex-direction: column-reverse; + } + + .modal__footer button { + width: 100%; + } +} diff --git a/frontend/src/styles/responsive.css b/frontend/src/styles/responsive.css new file mode 100644 index 0000000..9cb47d5 --- /dev/null +++ b/frontend/src/styles/responsive.css @@ -0,0 +1,240 @@ +/* S7 - Responsive & Accessibility */ + +/* Mobile - Small screens (max 640px) */ +@media (max-width: 640px) { + /* Sticky header with less padding */ + .header { + padding: var(--sp-3) var(--sp-4); + } + + .header__top { + margin-bottom: var(--sp-3); + } + + .header__nav { + gap: var(--sp-2); + } + + .header__nav-link { + padding: var(--sp-2) var(--sp-3); + font-size: var(--fs-sm); + } + + .header__title { + font-size: var(--fs-base); + } + + .header__identity { + gap: var(--sp-2); + font-size: var(--fs-sm); + } + + /* Main content */ + [data-testid='layout-main'] { + padding: var(--sp-4); + } + + .dashboard__section, + .settings-page__section, + .calendar__section, + .tips-page__section { + padding: var(--sp-4); + margin-bottom: var(--sp-4); + } + + /* Metrics - single column */ + .overview-metrics__list { + grid-template-columns: 1fr; + } + + /* Forms - full width */ + .form-group input, + .form-group select, + .form-group textarea { + max-width: none; + width: 100%; + } + + .form-grid { + grid-template-columns: 1fr; + } + + .form-grid .form-group input, + .form-grid .form-group select { + max-width: none; + } + + /* Tables */ + .days-breakdown__table, + .parental-leave-breakdown__table { + font-size: var(--fs-sm); + } + + .days-breakdown__table th, + .days-breakdown__table td, + .parental-leave-breakdown__table th, + .parental-leave-breakdown__table td { + padding: var(--sp-2) var(--sp-3); + } + + /* Modals */ + .qrcode-modal { + width: 95%; + padding: var(--sp-4); + max-height: 95vh; + } + + .qrcode-modal__qr-image { + max-width: 250px; + } + + .qrcode-modal__button-group { + flex-direction: column-reverse; + } + + .qrcode-modal__button { + width: 100%; + } + + /* Footer */ + footer { + padding: var(--sp-4); + font-size: var(--fs-sm); + } + + /* Button group */ + .modal__footer { + flex-direction: column-reverse; + } + + .modal__footer button { + width: 100%; + } + + /* Remove max-width on modal content */ + .modal-content { + width: 95%; + padding: var(--sp-4); + max-height: 95vh; + } + + /* Lists */ + .dashboard__break-item { + flex-direction: column; + align-items: flex-start; + gap: var(--sp-2); + } + + /* Loading spinner */ + .loading-spinner { + padding: var(--sp-5); + min-height: 200px; + } + + .loading-spinner__icon { + font-size: 36px; + } + + /* Progress chart */ + .progress-chart__stats { + flex-direction: column; + gap: var(--sp-2); + } + + /* Calendar responsiveness */ + .calendar__grid { + font-size: var(--fs-sm); + } + + .calendar__weekday, + .calendar-day { + padding: var(--sp-2); + } + + .calendar-day__number { + font-size: var(--fs-sm); + } +} + +/* Tablet - Medium screens (641px to 1024px) */ +@media (min-width: 641px) and (max-width: 1024px) { + .overview-metrics__list { + grid-template-columns: repeat(2, 1fr); + } + + .form-grid { + grid-template-columns: repeat(2, 1fr); + } + + [data-testid='layout-main'] { + padding: var(--sp-4); + } +} + +/* Accessibility - Reduced Motion */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + + .loading-spinner__icon { + animation: none; + } + + .progress-chart__fill { + transition: none; + } + + .header__nav-link, + .header__logout-button, + .header__child-select, + input, + select, + textarea, + button { + transition: none; + } +} + +/* High Contrast Mode */ +@media (prefers-contrast: more) { + :root { + --c-text: #000; + --c-accent: #1f4d2b; + --c-border: #333; + } + + button:focus-visible, + a:focus-visible, + input:focus-visible, + select:focus-visible, + textarea:focus-visible { + outline-width: 3px; + } +} + +/* Color Scheme Preference - Dark Mode (future-proofing) */ +@media (prefers-color-scheme: dark) { + /* Currently not implemented, but prepared */ +} + +/* Keyboard Navigation */ +:focus-visible { + outline: 2px solid var(--c-accent); + outline-offset: 2px; +} + +/* High Contrast Focus */ +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 2px solid var(--c-accent); + outline-offset: 2px; +}